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
@@ -1,12 +1,3 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
playwright-report
|
||||
test-results
|
||||
coverage
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# Nuxt
|
||||
data/
|
||||
.nuxt
|
||||
.output
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
*.log
|
||||
.npm
|
||||
.pnp.*
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Test
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
@@ -1,21 +1,14 @@
|
||||
# --- Stage 1: Build ---
|
||||
FROM node:20 AS builder
|
||||
|
||||
# Stage 1: Build
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci || npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 2: Production ---
|
||||
FROM node:20-slim AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/.output ./.output
|
||||
|
||||
# Stage 2: Serve with Nginx
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// App root – wraps all pages in default layout
|
||||
</script>
|
||||
@@ -1,427 +0,0 @@
|
||||
/* ============================================
|
||||
HMS Licht & Ton – Design Tokens & Custom CSS
|
||||
Dark Theme + Subtle Orange Accent
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
/* Brand Accent - used sparingly */
|
||||
--color-accent: #FA5C01;
|
||||
--color-accent-hover: #d4581a;
|
||||
--color-accent-light: rgba(236, 105, 37, 0.08);
|
||||
--color-accent-border: rgba(236, 105, 37, 0.25);
|
||||
--color-accent-dark: #b8461a;
|
||||
|
||||
/* Agent Zero Dark Theme Grays */
|
||||
--bg: #131313;
|
||||
--panel: #1a1a1a;
|
||||
--surface: #212121;
|
||||
--row: #272727;
|
||||
--card: #2d2d2d;
|
||||
--border: #444444a8;
|
||||
--secondary: #656565;
|
||||
--primary: #737a81;
|
||||
--text: #ffffff;
|
||||
--text-muted: #d4d4d4;
|
||||
|
||||
/* Aliases */
|
||||
--color-bg: var(--bg);
|
||||
--color-surface: var(--panel);
|
||||
--color-surface-alt: var(--surface);
|
||||
--color-card: var(--card);
|
||||
--color-text: var(--text);
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-text-light: var(--secondary);
|
||||
--color-border: var(--border);
|
||||
--color-border-strong: #555555;
|
||||
|
||||
/* Status Colors */
|
||||
--color-success: #4ade80;
|
||||
--color-success-bg: rgba(74, 222, 128, 0.08);
|
||||
--color-error: #f87171;
|
||||
--color-error-bg: rgba(248, 113, 113, 0.08);
|
||||
--color-warning: #fbbf24;
|
||||
--color-warning-bg: rgba(251, 191, 36, 0.08);
|
||||
--color-info: #60a5fa;
|
||||
--color-info-bg: rgba(96, 165, 250, 0.08);
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-heading: 'Inter', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
--text-5xl: 3rem;
|
||||
--text-6xl: 3.75rem;
|
||||
|
||||
/* Spacing */
|
||||
--space-xs: 0.25rem;
|
||||
--space-sm: 0.5rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2rem;
|
||||
--space-2xl: 3rem;
|
||||
--space-3xl: 4rem;
|
||||
--space-4xl: 6rem;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 3px;
|
||||
--radius-lg: 4px;
|
||||
--radius-xl: 4px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.3);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 250ms ease;
|
||||
--transition-slow: 400ms ease;
|
||||
|
||||
/* Layout */
|
||||
--header-height: 4rem;
|
||||
--max-width: 1280px;
|
||||
}
|
||||
|
||||
/* Base Reset */
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
line-height: 1.6;
|
||||
font-size: var(--text-base);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Tailwind dark overrides */
|
||||
.text-gray-900 { color: var(--text) !important; }
|
||||
.text-gray-800 { color: var(--text-muted) !important; }
|
||||
.text-gray-700 { color: var(--text-muted) !important; }
|
||||
.text-gray-600 { color: var(--text-muted) !important; }
|
||||
.text-gray-500 { color: var(--secondary) !important; }
|
||||
.text-gray-400 { color: var(--secondary) !important; }
|
||||
.text-white { color: var(--text) !important; }
|
||||
.bg-white { background-color: var(--panel) !important; }
|
||||
.bg-gray-100 { background-color: var(--surface) !important; }
|
||||
.bg-gray-200 { background-color: var(--row) !important; }
|
||||
.bg-gray-800 { background-color: var(--bg) !important; }
|
||||
.bg-gray-900 { background-color: var(--bg) !important; }
|
||||
.border-gray-200 { border-color: var(--border) !important; }
|
||||
.border-gray-700 { border-color: var(--border) !important; }
|
||||
.border-gray-100 { border-color: var(--border) !important; }
|
||||
.divide-gray-100 > * { border-color: var(--border) !important; }
|
||||
|
||||
/* Skip Link */
|
||||
.skip-link {
|
||||
position: absolute; top: -100px; left: 0;
|
||||
background: var(--color-accent); color: white;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
z-index: 9999; border-radius: 0 0 var(--radius-md) 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.skip-link:focus { top: 0; }
|
||||
|
||||
/* Focus visible */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--secondary); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--primary); }
|
||||
|
||||
/* Component: Header */
|
||||
.hms-header {
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
}
|
||||
.hms-logo-svg { height: 40px; width: auto; }
|
||||
.hms-logo-svg rect { stroke: var(--color-accent) !important; }
|
||||
|
||||
/* Component: Hero – with image background, subtle orange */
|
||||
.hms-hero {
|
||||
position: relative;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hms-hero-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.hms-hero-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(19,19,19,0.6) 0%, rgba(19,19,19,0.85) 50%, var(--bg) 100%);
|
||||
}
|
||||
|
||||
/* Component: Card */
|
||||
.hms-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow var(--transition-base), border-color var(--transition-base);
|
||||
}
|
||||
.hms-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
/* Component: Button – orange only on primary, subtle */
|
||||
.hms-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
font-family: var(--font-sans); font-weight: 600;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer; border: none; text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hms-btn-primary {
|
||||
background: var(--color-accent); color: white;
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
box-shadow: 0 0 0 0 rgba(236, 105, 37, 0);
|
||||
}
|
||||
.hms-btn-primary:hover {
|
||||
background: var(--color-accent-hover);
|
||||
box-shadow: 0 0 12px 2px rgba(236, 105, 37, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.hms-btn-primary:active { transform: translateY(0); box-shadow: 0 0 6px 0px rgba(236, 105, 37, 0.2); }
|
||||
.hms-btn-primary:disabled { background: var(--secondary); cursor: not-allowed; box-shadow: none; transform: none; }
|
||||
|
||||
.hms-btn-secondary {
|
||||
background: var(--surface); color: var(--text-muted);
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
}
|
||||
.hms-btn-secondary:hover {
|
||||
background: var(--row);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--text);
|
||||
box-shadow: 0 0 8px 0 rgba(236, 105, 37, 0.1);
|
||||
}
|
||||
.hms-btn-secondary:active { background: var(--surface); box-shadow: none; }
|
||||
|
||||
.hms-btn-ghost {
|
||||
background: transparent; color: var(--text-muted);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
.hms-btn-ghost:hover { background: var(--surface); color: var(--color-accent); border-color: var(--color-accent-border); }
|
||||
|
||||
/* Component: Nav Button (header menu items) */
|
||||
.hms-nav-btn {
|
||||
position: relative;
|
||||
transition: color var(--transition-fast), background-color var(--transition-fast), border-color var(--transition-fast) !important;
|
||||
}
|
||||
.hms-nav-btn:hover {
|
||||
color: var(--color-accent) !important;
|
||||
background: var(--color-accent-light) !important;
|
||||
}
|
||||
.hms-nav-btn:hover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 2px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60%; height: 2px;
|
||||
background: var(--color-accent);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.hms-nav-btn[aria-current="page"]:hover {
|
||||
color: var(--color-accent) !important;
|
||||
background: var(--color-accent-light) !important;
|
||||
}
|
||||
|
||||
/* Component: Badge – subtle orange */
|
||||
.hms-badge {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 2px var(--space-sm);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs); font-weight: 600;
|
||||
}
|
||||
.hms-badge-primary {
|
||||
background: rgba(236, 105, 37, 0.12);
|
||||
color: var(--color-accent);
|
||||
border: 1px solid rgba(236, 105, 37, 0.2);
|
||||
}
|
||||
.hms-badge-gray { background: var(--surface); color: var(--text-muted); }
|
||||
.hms-badge-success { background: var(--color-success-bg); color: var(--color-success); }
|
||||
.hms-badge-error { background: var(--color-error-bg); color: var(--color-error); }
|
||||
|
||||
/* Component: Input */
|
||||
.hms-input {
|
||||
width: 100%;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
background: var(--surface); color: var(--text);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
.hms-input::placeholder { color: var(--secondary); }
|
||||
.hms-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px rgba(236, 105, 37, 0.1);
|
||||
}
|
||||
.hms-input:disabled { background: var(--row); color: var(--secondary); cursor: not-allowed; }
|
||||
.hms-input-error { border-color: var(--color-error); }
|
||||
.hms-input-error:focus { box-shadow: 0 0 0 2px rgba(248, 113, 113, 0.1); }
|
||||
select.hms-input { background: var(--surface); color: var(--text); }
|
||||
select.hms-input option { background: var(--panel); color: var(--text); }
|
||||
|
||||
/* Component: Loading Skeleton */
|
||||
.hms-skeleton {
|
||||
background: linear-gradient(90deg, var(--surface) 25%, var(--row) 50%, var(--surface) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Component: Spinner */
|
||||
.hms-spinner {
|
||||
width: 24px; height: 24px;
|
||||
border: 3px solid var(--surface);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Component: Service Icon Circle – subtle, no large orange area */
|
||||
.hms-icon-circle {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.25rem; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Component: Speaker Grid */
|
||||
.hms-speaker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.hms-speaker-item {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
text-align: center;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
.hms-speaker-item:hover {
|
||||
border-color: var(--color-accent-border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.hms-speaker-icon-wrap { display: inline-flex; align-items: center; justify-content: center; margin: 0 auto var(--space-sm); }
|
||||
.hms-speaker-icon-wrap svg { width: 48px; height: 48px; color: var(--text-muted); transition: color var(--transition-base); }
|
||||
.hms-speaker-item:hover .hms-speaker-icon-wrap svg { color: var(--color-accent); }
|
||||
.hms-speaker-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.hms-speaker-type {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
/* Component: Footer */
|
||||
.hms-footer {
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Gallery Grid */
|
||||
.hms-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
/* Equipment Card */
|
||||
.hms-eq-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transition: all var(--transition-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
.hms-eq-card:hover {
|
||||
border-color: var(--color-accent-border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Filter Chip */
|
||||
.hms-chip {
|
||||
display: inline-flex; align-items: center; gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm); font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface); color: var(--text-muted);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.hms-chip:hover { border-color: var(--secondary); }
|
||||
.hms-chip.active {
|
||||
background: var(--color-accent); color: white;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Section divider – subtle */
|
||||
.hms-section-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent 0%, var(--border) 50%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Mobile Menu Animation */
|
||||
.mobile-menu-enter-active, .mobile-menu-leave-active { transition: all var(--transition-base); }
|
||||
.mobile-menu-enter-from, .mobile-menu-leave-to { opacity: 0; transform: translateY(-10px); }
|
||||
|
||||
/* Fade Transition */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity var(--transition-base); }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
/* Link colors */
|
||||
a { color: var(--color-accent); }
|
||||
a:hover { color: var(--color-accent-hover); }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
:root { --header-height: 3.5rem; }
|
||||
.hms-speaker-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Ebene_1" x="0px" y="0px" width="200px" height="200px" viewBox="0 0 200 200" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#FFFFFF" d="M166.266,172.872h-37.687v-60.718H74.226v60.718H36.539V26.956h37.687v56.335h54.354V26.956h37.687 V172.872z"></path>
|
||||
</g>
|
||||
|
||||
<rect x="23.598" y="15.343" fill="none" stroke="#EC6925" stroke-width="15.1525" stroke-miterlimit="10" width="155.612" height="168.874"></rect>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 503 B |
@@ -1,52 +0,0 @@
|
||||
<template>
|
||||
<footer class="hms-footer mt-16" role="contentinfo">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-4" style="color: var(--text)">
|
||||
<HmsLogo :size="36" />
|
||||
<div><div class="font-bold text-sm">HMS Licht & Ton</div><div class="text-xs" style="color: var(--secondary)">Veranstaltungstechnik</div></div>
|
||||
</div>
|
||||
<p class="text-sm leading-relaxed" style="color: var(--secondary)">Hammerschmidt u. Mössle GbR – seit über 20 Jahren Ihr Partner für professionelle Veranstaltungstechnik in der Region Leipheim / Ellzee.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-4" style="color: var(--text)">Navigation</h3>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li><NuxtLink to="/" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">Home</NuxtLink></li>
|
||||
<li><NuxtLink to="/referenzen" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">Referenzen</NuxtLink></li>
|
||||
<li><NuxtLink to="/mietkatalog" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">Mietkatalog</NuxtLink></li>
|
||||
<li><NuxtLink to="/kontakt" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">Kontakt</NuxtLink></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-4" style="color: var(--text)">Kontakt</h3>
|
||||
<ul class="space-y-2 text-sm" style="color: var(--secondary)">
|
||||
<li>Grockelhofen 10, 89340 Leipheim</li>
|
||||
<li><a href="tel:+498221204433" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">+49 (0) 8221 / 204433</a></li>
|
||||
<li><a href="mailto:info@hms-licht-ton.de" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">info@hms-licht-ton.de</a></li>
|
||||
<li class="flex gap-3 pt-2">
|
||||
<a href="https://facebook.com" target="_blank" rel="noopener" aria-label="Facebook" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]"><svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg></a>
|
||||
<a href="https://instagram.com" target="_blank" rel="noopener" aria-label="Instagram" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]"><svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-4" style="color: var(--text)">Rechtliches</h3>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li><NuxtLink to="/impressum" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">Impressum</NuxtLink></li>
|
||||
<li><NuxtLink to="/datenschutz" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">DSGVO</NuxtLink></li>
|
||||
<li><NuxtLink to="/agb-vermietung" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">AGB Vermietung</NuxtLink></li>
|
||||
<li><NuxtLink to="/admin" style="color: var(--secondary)" class="hover:text-[var(--color-accent)] text-xs">Admin-Login</NuxtLink></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t mt-8 pt-6 text-center text-xs" :style="{ borderColor: 'var(--border)', color: 'var(--secondary)' }">
|
||||
© {{ year }} Hammerschmidt u. Mössle GbR · Veranstaltungstechnik · Leipheim / Ellzee
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const year = new Date().getFullYear()
|
||||
</script>
|
||||
@@ -1,65 +0,0 @@
|
||||
<template>
|
||||
<header class="hms-header" role="banner">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between" :style="{ height: 'var(--header-height)' }">
|
||||
<NuxtLink to="/" class="flex items-center gap-2" style="color: var(--text)" aria-label="HMS Licht & Ton Startseite">
|
||||
<HmsLogo :size="40" />
|
||||
<div class="hidden sm:block leading-tight">
|
||||
<div class="font-bold text-base" style="color: var(--text)">HMS Licht & Ton</div>
|
||||
<div class="text-xs" style="color: var(--secondary)">Veranstaltungstechnik</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<nav class="hidden md:flex items-center gap-1" role="navigation" aria-label="Hauptnavigation">
|
||||
<NuxtLink v-for="item in navItems" :key="item.to" :to="item.to"
|
||||
class="hms-nav-btn px-4 py-2 rounded-lg text-sm font-medium"
|
||||
:style="isActive(item.to) ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }"
|
||||
:aria-current="isActive(item.to) ? 'page' : undefined">
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/warenkorb" class="hms-btn hms-btn-primary text-sm ml-2 px-4 py-2">
|
||||
<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="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>
|
||||
Warenkorb
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<button @click="mobileMenuOpen = !mobileMenuOpen" class="md:hidden p-2 rounded-lg" :style="{ color: 'var(--text-muted)' }" :aria-expanded="mobileMenuOpen" aria-label="Menü öffnen/schließen">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-if="!mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="mobile-menu">
|
||||
<nav v-if="mobileMenuOpen" id="mobile-menu" class="md:hidden border-t" :style="{ borderColor: 'var(--border)', background: 'var(--panel)' }" role="navigation" aria-label="Mobile Navigation">
|
||||
<div class="px-4 py-3 space-y-1">
|
||||
<NuxtLink v-for="item in navItems" :key="item.to" :to="item.to" @click="mobileMenuOpen = false"
|
||||
class="hms-nav-btn block w-full text-left px-4 py-3 rounded-lg text-sm font-medium"
|
||||
:style="isActive(item.to) ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }">
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/warenkorb" @click="mobileMenuOpen = false" class="block w-full text-left px-4 py-3 rounded-lg text-sm font-medium text-white" style="background: var(--color-accent)">🛒 Warenkorb</NuxtLink>
|
||||
</div>
|
||||
</nav>
|
||||
</transition>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const mobileMenuOpen = ref(false)
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Home', to: '/' },
|
||||
{ label: 'Referenzen', to: '/referenzen' },
|
||||
{ label: 'Mietkatalog', to: '/mietkatalog' },
|
||||
{ label: 'Kontakt', to: '/kontakt' }
|
||||
]
|
||||
|
||||
function isActive(to: string): boolean {
|
||||
if (to === '/') return route.path === '/'
|
||||
return route.path.startsWith(to)
|
||||
}
|
||||
</script>
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<div class="text-center py-16 px-4" role="status">
|
||||
<div class="text-6xl mb-4" aria-hidden="true">{{ icon }}</div>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text)">{{ title }}</h3>
|
||||
<p class="max-w-md mx-auto mb-6" style="color: var(--secondary)">{{ message }}</p>
|
||||
<button v-if="actionLabel" @click="$emit('action')" class="hms-btn hms-btn-primary">{{ actionLabel }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ icon?: string; title: string; message: string; actionLabel?: string }>(), { icon: '🔍' })
|
||||
defineEmits<{ action: [] }>()
|
||||
</script>
|
||||
@@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<div class="hms-eq-card" @click="$emit('click', item)" role="article" tabindex="0" @keydown.enter="$emit('click', item)">
|
||||
<div class="aspect-[4/3] flex items-center justify-center relative overflow-hidden" style="background: var(--surface)">
|
||||
<img v-if="item.image" :src="item.image" :alt="item.name" loading="lazy" class="absolute inset-0 w-full h-full object-cover" />
|
||||
<div v-else class="text-4xl" style="color: var(--secondary)">📦</div>
|
||||
<span v-if="item.category" class="hms-badge hms-badge-primary absolute top-3 left-3">{{ item.category }}</span>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-sm mb-1 truncate" style="color: var(--text)">{{ item.name }}</h3>
|
||||
<p class="text-xs mb-3 line-clamp-2" style="color: var(--secondary)">{{ item.description }}</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs" style="color: var(--secondary)">{{ item.code }}</span>
|
||||
<button @click.stop="$emit('add-to-cart', item)" class="hms-btn hms-btn-ghost text-xs px-3 py-1.5" style="color: var(--color-accent)" :aria-label="item.name + ' zum Warenkorb hinzufügen'">+ Hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ item: Record<string, any> }>()
|
||||
defineEmits<{ click: [item: any]; 'add-to-cart': [item: any] }>()
|
||||
</script>
|
||||
@@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<div class="text-center py-16 px-4" role="alert">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" :style="{ background: 'var(--color-error-bg)', color: 'var(--color-error)' }">
|
||||
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text)">{{ title }}</h3>
|
||||
<p class="max-w-md mx-auto mb-6" style="color: var(--secondary)">{{ message }}</p>
|
||||
<button @click="$emit('retry')" class="hms-btn hms-btn-primary">Erneut versuchen</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ title?: string; message: string }>(), { title: 'Ein Fehler ist aufgetreten' })
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
@@ -1,10 +0,0 @@
|
||||
<template>
|
||||
<svg class="hms-logo-svg" viewBox="0 0 200 200" :style="`height:${size}px;width:auto`" aria-label="HMS Licht & Ton Logo" role="img">
|
||||
<path fill="currentColor" d="M166.266,172.872h-37.687v-60.718H74.226v60.718H36.539V26.956h37.687v56.335h54.354V26.956h37.687V172.872z" />
|
||||
<rect x="23.598" y="15.343" fill="none" stroke="#FA5C01" stroke-width="15.1525" stroke-miterlimit="10" width="155.612" height="168.874" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 40 })
|
||||
</script>
|
||||
@@ -1,29 +0,0 @@
|
||||
<template>
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="space-y-4">
|
||||
<div class="skeleton-shimmer rounded-md" style="height: 32px; width: 60%" />
|
||||
<div class="skeleton-shimmer rounded-md" style="height: 20px" />
|
||||
<div class="skeleton-shimmer rounded-md" style="height: 20px; width: 90%" />
|
||||
<div class="skeleton-shimmer rounded-md" style="height: 20px" />
|
||||
<div class="skeleton-shimmer rounded-md" style="height: 20px; width: 80%" />
|
||||
</div>
|
||||
|
||||
<!-- Content State -->
|
||||
<article v-else>
|
||||
<h1 class="text-3xl font-bold text-text mb-6">{{ title }}</h1>
|
||||
<div class="prose prose-invert max-w-none text-text-muted" v-html="content" />
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
title: string;
|
||||
content?: string;
|
||||
loading?: boolean;
|
||||
}>(), {
|
||||
content: "",
|
||||
loading: false,
|
||||
});
|
||||
</script>
|
||||
@@ -1,12 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6" aria-live="polite" aria-busy="true">
|
||||
<div v-for="i in count" :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-3"><div class="hms-skeleton h-4 w-3/4"></div><div class="hms-skeleton h-3 w-full"></div><div class="hms-skeleton h-3 w-1/2"></div><div class="flex justify-between items-center pt-2"><div class="hms-skeleton h-3 w-20"></div><div class="hms-skeleton h-6 w-24" style="border-radius:var(--radius-md)"></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ count?: number }>(), { count: 6 })
|
||||
</script>
|
||||
@@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<div class="hms-card p-6">
|
||||
<div class="hms-icon-circle mb-4"><span aria-hidden="true">{{ icon }}</span></div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text)">{{ title }}</h3>
|
||||
<p class="text-sm leading-relaxed" style="color: var(--text-muted)">{{ description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ icon: string; title: string; description: string }>()
|
||||
</script>
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { $Fetch } from "nitropack";
|
||||
|
||||
/**
|
||||
* Base API client composable.
|
||||
* Uses server-side apiBase for SSR requests, public apiBase for client-side.
|
||||
*/
|
||||
export function useApi() {
|
||||
const config = useRuntimeConfig();
|
||||
// On server: config.apiBase (http://backend:8000)
|
||||
// On client: config.public.apiBase (/api)
|
||||
const apiBase = import.meta.server ? config.apiBase : config.public.apiBase;
|
||||
|
||||
const client: $Fetch = $fetch.create({
|
||||
baseURL: apiBase,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
async function get<T>(url: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
||||
return client<T>(url, {
|
||||
method: "GET",
|
||||
params: params as Record<string, string> | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function post<T>(url: string, body?: unknown): Promise<T> {
|
||||
return client<T>(url, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
async function put<T>(url: string, body?: unknown): Promise<T> {
|
||||
return client<T>(url, {
|
||||
method: "PUT",
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
async function del<T>(url: string): Promise<T> {
|
||||
return client<T>(url, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
apiBase,
|
||||
client,
|
||||
get,
|
||||
post,
|
||||
put,
|
||||
del,
|
||||
};
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useCartStore } from "~/stores/cart";
|
||||
import { storeToRefs } from "pinia";
|
||||
import type { EquipmentItem } from "~/composables/useEquipment";
|
||||
|
||||
/**
|
||||
* Cart composable – wraps the Pinia cart store for convenient use in components.
|
||||
* Provides reactive refs and action methods.
|
||||
*/export function useCart() {
|
||||
const store = useCartStore();
|
||||
|
||||
const { items, totalCount, isEmpty, hasItems, apiItems } = storeToRefs(store);
|
||||
|
||||
function addItem(equipment: EquipmentItem): void {
|
||||
store.addItem(equipment);
|
||||
}
|
||||
|
||||
function addItemByFields(item: {
|
||||
equipment_id: number;
|
||||
name: string;
|
||||
rental_price: number | null;
|
||||
image_url: string | null;
|
||||
}): void {
|
||||
store.addItemByFields(item);
|
||||
}
|
||||
|
||||
function removeItem(equipmentId: number): void {
|
||||
store.removeItem(equipmentId);
|
||||
}
|
||||
|
||||
function updateQuantity(equipmentId: number, quantity: number): void {
|
||||
store.updateQuantity(equipmentId, quantity);
|
||||
}
|
||||
|
||||
function incrementQuantity(equipmentId: number): void {
|
||||
store.incrementQuantity(equipmentId);
|
||||
}
|
||||
|
||||
function decrementQuantity(equipmentId: number): void {
|
||||
store.decrementQuantity(equipmentId);
|
||||
}
|
||||
|
||||
function clearCart(): void {
|
||||
store.clearCart();
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
totalCount,
|
||||
isEmpty,
|
||||
hasItems,
|
||||
apiItems,
|
||||
addItem,
|
||||
addItemByFields,
|
||||
removeItem,
|
||||
updateQuantity,
|
||||
incrementQuantity,
|
||||
decrementQuantity,
|
||||
clearCart,
|
||||
};
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Equipment API composable.
|
||||
* Wraps useApi to provide typed equipment list, detail, and categories endpoints.
|
||||
*/
|
||||
|
||||
export interface EquipmentItem {
|
||||
id: number;
|
||||
rentman_id: string;
|
||||
name: string;
|
||||
number: string | null;
|
||||
category: string | null;
|
||||
description: string | null;
|
||||
image_url: string | null;
|
||||
rental_price: number | null;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface EquipmentDetail {
|
||||
id: number;
|
||||
rentman_id: string;
|
||||
name: string;
|
||||
number: string | null;
|
||||
category: string | null;
|
||||
description: string | null;
|
||||
specifications: Record<string, unknown> | null;
|
||||
images: string[] | null;
|
||||
rental_price: number | null;
|
||||
brand: string | null;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedEquipment {
|
||||
items: EquipmentItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export type SortOption = "name_asc" | "name_desc";
|
||||
|
||||
export function useEquipment() {
|
||||
const api = useApi();
|
||||
|
||||
/**
|
||||
* Fetch paginated equipment list with search, category filter, and sort.
|
||||
*/
|
||||
async function list(params: {
|
||||
search?: string;
|
||||
category?: string;
|
||||
sort?: SortOption;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
} = {}): Promise<PaginatedEquipment> {
|
||||
return api.get<PaginatedEquipment>("/api/equipment", {
|
||||
search: params.search || undefined,
|
||||
category: params.category || undefined,
|
||||
sort: params.sort || "name_asc",
|
||||
page: params.page || 1,
|
||||
page_size: params.page_size || 20,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single equipment detail by ID.
|
||||
*/
|
||||
async function detail(id: number | string): Promise<EquipmentDetail> {
|
||||
return api.get<EquipmentDetail>(`/api/equipment/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all available equipment categories.
|
||||
*/
|
||||
async function categories(): Promise<string[]> {
|
||||
return api.get<string[]>("/api/equipment/categories");
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
detail,
|
||||
categories,
|
||||
};
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<AppHeader />
|
||||
<main id="main-content" class="flex-1" role="main">
|
||||
<div class="max-w-md mx-auto px-4 py-24 text-center">
|
||||
<div class="text-8xl font-bold mb-4" style="color: var(--secondary)">404</div>
|
||||
<h1 class="text-2xl font-semibold mb-2" style="color: var(--text)">Seite nicht gefunden</h1>
|
||||
<p class="mb-8" style="color: var(--secondary)">Die angeforderte Seite existiert nicht.</p>
|
||||
<NuxtLink to="/" class="hms-btn hms-btn-primary">Zur Startseite</NuxtLink>
|
||||
</div>
|
||||
</main>
|
||||
<AppFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { NuxtError } from "#app";
|
||||
|
||||
const props = defineProps<{ error: NuxtError }>();
|
||||
|
||||
useHead({
|
||||
title: `${props.error.statusCode} – HMS Licht & Ton`,
|
||||
meta: [
|
||||
{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" },
|
||||
],
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
|
||||
<meta name="theme-color" content="#131313" />
|
||||
<meta name="description" content="HMS Licht & Ton – Professionelle Veranstaltungstechnik aus Leipheim/Ellzee. Vermietung, Verkauf, Personal, Transport von Tontechnik und Lichttechnik." />
|
||||
<meta name="author" content="Hammerschmidt u. Mössle GbR" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" />
|
||||
<title>HMS Licht & Ton – Veranstaltungstechnik</title>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "LocalBusiness",
|
||||
"name": "HMS Licht & Ton – Hammerschmidt u. Mössle GbR",
|
||||
"description": "Professionelle Veranstaltungstechnik aus Leipheim/Ellzee. Vermietung, Verkauf, Personal, Transport.",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "Grockelhofen 10",
|
||||
"addressLocality": "Leipheim",
|
||||
"postalCode": "89340",
|
||||
"addressCountry": "DE"
|
||||
},
|
||||
"telephone": "+498221204433",
|
||||
"email": "info@hms-licht-ton.de",
|
||||
"url": "https://hms.media-on.de",
|
||||
"areaServed": "Süddeutschland",
|
||||
"knowsAbout": ["Tontechnik", "Lichttechnik", "Rigging", "Veranstaltungstechnik"]
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,14 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a>
|
||||
<AppHeader />
|
||||
<main id="main-content" class="flex-1" role="main">
|
||||
<slot />
|
||||
</main>
|
||||
<AppFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Default layout – Header + Main + Footer
|
||||
</script>
|
||||
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 3000;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// nuxt.config.ts – HMS Licht & Ton Frontend Configuration
|
||||
import type { NuxtConfig } from "nuxt/schema";
|
||||
|
||||
export default defineNuxtConfig({
|
||||
devtools: { enabled: false },
|
||||
modules: ["@nuxtjs/tailwindcss", "@pinia/nuxt"],
|
||||
css: ["~/assets/css/main.css"],
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: { lang: "de" },
|
||||
meta: [
|
||||
{ charset: "utf-8" },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||
{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" },
|
||||
{ name: "theme-color", content: "#131313" },
|
||||
{ name: "description", content: "HMS Licht & Ton – Professionelle Veranstaltungstechnik aus Leipheim/Ellzee. Vermietung, Verkauf, Personal, Transport von Tontechnik und Lichttechnik." },
|
||||
{ name: "author", content: "Hammerschmidt u. Mössle GbR" },
|
||||
],
|
||||
link: [
|
||||
{ rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" },
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap",
|
||||
},
|
||||
],
|
||||
script: [
|
||||
{
|
||||
type: "application/ld+json",
|
||||
children: JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "LocalBusiness",
|
||||
name: "HMS Licht & Ton – Hammerschmidt u. Mössle GbR",
|
||||
description: "Professionelle Veranstaltungstechnik aus Leipheim/Ellzee. Vermietung, Verkauf, Personal, Transport.",
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: "Grockelhofen 10",
|
||||
addressLocality: "Leipheim",
|
||||
postalCode: "89340",
|
||||
addressCountry: "DE",
|
||||
},
|
||||
telephone: "+498221204433",
|
||||
email: "info@hms-licht-ton.de",
|
||||
url: "https://hms.media-on.de",
|
||||
areaServed: "Süddeutschland",
|
||||
knowsAbout: ["Tontechnik", "Lichttechnik", "Rigging", "Veranstaltungstechnik"],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
runtimeConfig: {
|
||||
// Server-only: used by SSR to call backend in Docker network
|
||||
apiBase: process.env.API_BASE_URL || "http://backend:8000",
|
||||
public: {
|
||||
// Client-side: relative URL goes through Nuxt server proxy
|
||||
apiBase: "/api",
|
||||
},
|
||||
},
|
||||
routeRules: {
|
||||
"/mietkatalog": { ssr: true },
|
||||
"/mietkatalog/**": { ssr: true },
|
||||
"/warenkorb": { ssr: false },
|
||||
"/mietanfrage": { ssr: false },
|
||||
"/admin": { ssr: false },
|
||||
},
|
||||
typescript: {
|
||||
strict: true,
|
||||
typeCheck: false,
|
||||
},
|
||||
tailwindcss: {
|
||||
cssPath: "~/assets/css/main.css",
|
||||
configPath: "tailwind.config.ts",
|
||||
},
|
||||
nitro: {
|
||||
compressPublicAssets: true,
|
||||
routeRules: {
|
||||
"/api/**": {
|
||||
proxy: "http://backend:8000/api/**",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,34 +1,26 @@
|
||||
{
|
||||
"name": "hms-licht-ton-frontend",
|
||||
"name": "hms-licht-ton-frontend-react",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"typecheck": "nuxt typecheck",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run --reporter verbose",
|
||||
"test:e2e": "playwright test"
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"nuxt": "^3.14.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.0"
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/test-utils": "^3.14.0",
|
||||
"@nuxtjs/tailwindcss": "^6.12.0",
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@vue/test-utils": "^2.4.5",
|
||||
"happy-dom": "^15.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^3.2.0",
|
||||
"vue-tsc": "^2.1.0"
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<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>
|
||||
@@ -1,121 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hero with background image -->
|
||||
<section class="hms-hero py-20 sm:py-32" aria-labelledby="hero-title">
|
||||
<div class="hms-hero-bg" style="background-image: url('https://images.unsplash.com/photo-1470229722913-7c0e2dbbafd3?w=1920&q=80')"></div>
|
||||
<div class="hms-hero-overlay"></div>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div class="max-w-2xl">
|
||||
<span class="hms-badge hms-badge-primary mb-4">Seit 2003 · Über 20 Jahre Veranstaltungstechnik</span>
|
||||
<h1 id="hero-title" class="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight mb-6" style="color: var(--text)">
|
||||
Professionelle Technik<br><span style="color: var(--color-accent)">für Ihre Veranstaltung</span>
|
||||
</h1>
|
||||
<p class="text-lg sm:text-xl mb-8 max-w-xl" style="color: var(--text-muted)">
|
||||
Von der Firmenevent-Beschallung bis zur Open-Air-Großproduktion: HMS Licht & Ton liefert Tontechnik, Lichttechnik, Rigging und Komplettlösungen aus Leipheim und Ellzee.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<button @click="navigate('/mietkatalog')" class="hms-btn hms-btn-primary text-base px-8 py-4">
|
||||
Mietkatalog öffnen
|
||||
<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="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
|
||||
</button>
|
||||
<button @click="navigate('/kontakt')" class="hms-btn hms-btn-secondary text-base px-8 py-4">Beratung anfragen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Speaker Grid -->
|
||||
<section class="py-12 sm:py-16" :style="{ background: 'var(--bg)' }" aria-labelledby="speaker-title">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-8">
|
||||
<span class="text-sm font-semibold uppercase tracking-wider" style="color: var(--color-accent)">Equipment-Highlights</span>
|
||||
<h2 id="speaker-title" class="text-2xl sm:text-3xl font-bold mt-1" style="color: var(--text)">Lautsprecher & Strahler aus unserem Mietlager</h2>
|
||||
</div>
|
||||
<div class="hms-speaker-grid">
|
||||
<div v-for="sp in speakers" :key="sp.name" class="hms-speaker-item" @click="navigate('/mietkatalog')" role="button" tabindex="0" @keydown.enter="navigate('/mietkatalog')">
|
||||
<SpeakerIcon :type="sp.type" />
|
||||
<div class="hms-speaker-name">{{ sp.name }}</div>
|
||||
<div class="hms-speaker-type">{{ sp.category }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Über uns -->
|
||||
<section class="py-16 sm:py-20" :style="{ background: 'var(--panel)' }" aria-labelledby="about-title">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<span class="text-sm font-semibold uppercase tracking-wider mb-2" style="color: var(--color-accent)">Unternehmen</span>
|
||||
<h2 id="about-title" class="text-3xl sm:text-4xl font-bold mb-6" style="color: var(--text)">Ihr Technik-Partner mit Erfahrung</h2>
|
||||
<p class="leading-relaxed mb-4" style="color: var(--text-muted)">Die <strong style="color: var(--text)">Hammerschmidt u. Mössle GbR</strong> ist seit 2003 Ihr zuverlässiger Partner für Veranstaltungstechnik in der Region Leipheim / Ellzee und im gesamten süddeutschen Raum.</p>
|
||||
<p class="leading-relaxed mb-4" style="color: var(--text-muted)">Mit einem Mietlager von über 1.000 Geräten, einer eigenen Werkstatt und erfahrenem Personal realisieren wir Veranstaltungen jeder Größenordnung.</p>
|
||||
<div class="grid grid-cols-3 gap-4 mt-8">
|
||||
<div class="text-center"><div class="text-3xl font-bold" style="color: var(--color-accent)">20+</div><div class="text-sm" style="color: var(--secondary)">Jahre Erfahrung</div></div>
|
||||
<div class="text-center"><div class="text-3xl font-bold" style="color: var(--color-accent)">1.000+</div><div class="text-sm" style="color: var(--secondary)">Geräte im Lager</div></div>
|
||||
<div class="text-center"><div class="text-3xl font-bold" style="color: var(--color-accent)">2</div><div class="text-sm" style="color: var(--secondary)">Standorte</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div class="aspect-[4/3] rounded-lg overflow-hidden shadow-xl">
|
||||
<img src="https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=800&q=80" alt="Veranstaltungstechnik Setup" loading="lazy" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Leistungen -->
|
||||
<section class="py-16 sm:py-20" :style="{ background: 'var(--bg)' }" aria-labelledby="services-title">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<span class="text-sm font-semibold uppercase tracking-wider mb-2" style="color: var(--color-accent)">Leistungen</span>
|
||||
<h2 id="services-title" class="text-3xl sm:text-4xl font-bold" style="color: var(--text)">Von der Vermietung bis zur Komplettbetreuung</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<ServiceCard v-for="s in services" :key="s.title" :icon="s.icon" :title="s.title" :description="s.description" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="py-16 sm:py-20" :style="{ background: 'var(--panel)' }" aria-labelledby="rental-title">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="rounded-lg p-8 sm:p-12 lg:p-16 text-center" style="background: var(--bg); border: 1px solid var(--border)">
|
||||
<h2 id="rental-title" class="text-3xl sm:text-4xl font-bold mb-4" style="color: var(--text)">Online-Mietkatalog</h2>
|
||||
<p class="max-w-2xl mx-auto mb-8" style="color: var(--text-muted)">Durchsuchen Sie unser Equipment-Sortiment und stellen Sie Ihre Mietanfrage direkt online zusammen.</p>
|
||||
<button @click="navigate('/mietkatalog')" class="hms-btn hms-btn-primary text-base px-8 py-4">
|
||||
Katalog durchsuchen
|
||||
<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="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const services = [
|
||||
{ icon: '🔊', title: 'Vermietung', description: 'Lautsprecher, Mischpulte, Lichtanlagen, Rigging und Komplett-Setups aus unserem 1.000+ Geräte umfassenden Mietlager in Ellzee. Geprüftes Equipment, sofort einsatzbereit.' },
|
||||
{ icon: '🛒', title: 'Verkauf', description: 'Neu- und Gebrauchtgeräte führender Hersteller mit Beratung, Inbetriebnahme und Service. Wir liefern nicht nur – wir konfigurieren Ihre Anlage einsatzfertig.' },
|
||||
{ icon: '👷', title: 'Personal', description: 'Qualifizierte Tontechniker, Lichttechniker und Rigger für Aufbau, Betrieb und Abbau. Mit TÜV-geprüfter Ausbildung und umfangreicher Praxiserfahrung.' },
|
||||
{ icon: '🚚', title: 'Transport', description: 'Eigene Transportfahrzeuge für sichere An- und Ablieferung. Inklusive Verladekonzept, Positionierung und Rückholung – kundengerecht terminiert.' },
|
||||
{ icon: '📦', title: 'Lagerung', description: 'Klimatisierte und trockene Lagerung für Equipment und Veranstaltungsbestände. Mit Bestandsmanagement und regelmäßiger Funktionsprüfung.' },
|
||||
{ icon: '🔧', title: 'Werkstatt', description: 'Herstellerübergreifende Reparatur und Wartung in unserer eigenen Werkstatt. Regelmäßige DGUV-Prüfungen und Kalibrierung für Ihren sicheren Betrieb.' },
|
||||
{ icon: '⚡', title: 'Installation', description: 'Festinstallationen in Schaufenstern, Lokalen und Veranstaltungsstätten. Von der Planung über die Elektroinstallation bis zur Abnahme – alles aus einer Hand.' },
|
||||
{ icon: '📅', title: 'Booking', description: 'Vermittlung von Künstlern und Technik-Personal für Ihre Veranstaltung. Mit unserem branchenweiten Netzwerk finden wir die passenden Professionals für Ihr Event.' }
|
||||
]
|
||||
const speakers = [
|
||||
{ type: 'linearray', name: 'L-Acoustics K2', category: 'Line Array' },
|
||||
{ type: 'subwoofer', name: 'L-Acoustics KS28', category: 'Subwoofer' },
|
||||
{ type: 'pointsource', name: 'd&b T10', category: 'Point Source' },
|
||||
{ type: 'column', name: 'd&b KSL8', category: 'Column Array' },
|
||||
{ type: 'monitor', name: 'd&b M4', category: 'Monitor' },
|
||||
{ type: 'movinghead', name: 'Robe Pointe', category: 'Moving Head' }
|
||||
]
|
||||
|
||||
function navigate(route: string) {
|
||||
navigateTo(route)
|
||||
if (import.meta.client) window.scrollTo(0, 0)
|
||||
}
|
||||
</script>
|
||||
@@ -1,62 +0,0 @@
|
||||
<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>
|
||||
@@ -1,104 +0,0 @@
|
||||
<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>
|
||||
@@ -1,64 +0,0 @@
|
||||
<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>
|
||||
@@ -1,55 +0,0 @@
|
||||
<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>
|
||||
@@ -1,84 +0,0 @@
|
||||
<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>
|
||||
@@ -1,24 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
timeout: 30000,
|
||||
retries: 1,
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
headless: true,
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "npm run dev",
|
||||
url: "http://localhost:3000",
|
||||
timeout: 60000,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Plugin } from "nuxt/app";
|
||||
|
||||
/**
|
||||
* Client-side Pinia persistence plugin.
|
||||
* Syncs the cart store to localStorage on every mutation and hydrates on load.
|
||||
*/const STORAGE_KEY = "hms-cart";
|
||||
|
||||
export default defineNuxtPlugin(({ pinia }) => {
|
||||
// Hydrate from localStorage on plugin load
|
||||
if (import.meta.client) {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed && Array.isArray(parsed.items)) {
|
||||
const cartStore = pinia._s.get("cart");
|
||||
if (cartStore) {
|
||||
cartStore.$patch({ items: parsed.items });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to hydrate cart from localStorage:", e);
|
||||
}
|
||||
|
||||
// Subscribe to cart store changes and persist
|
||||
const cartStore = pinia._s.get("cart");
|
||||
if (cartStore) {
|
||||
cartStore.$subscribe((_mutation, state) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ items: state.items }),
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("Failed to persist cart to localStorage:", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}) as Plugin;
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<path fill="#ffffff" d="M166.266,172.872h-37.687v-60.718H74.226v60.718H36.539V26.956h37.687v56.335h54.354V26.956h37.687V172.872z" />
|
||||
<rect x="23.598" y="15.343" fill="none" stroke="#FA5C01" stroke-width="15.1525" stroke-miterlimit="10" width="155.612" height="168.874" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 346 B |
|
After Width: | Height: | Size: 634 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 608 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 566 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 541 KiB |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Ebene_1" x="0px" y="0px" width="200px" height="200px" viewBox="0 0 200 200" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#FFFFFF" d="M166.266,172.872h-37.687v-60.718H74.226v60.718H36.539V26.956h37.687v56.335h54.354V26.956h37.687 V172.872z"></path>
|
||||
</g>
|
||||
|
||||
<rect x="23.598" y="15.343" fill="none" stroke="#EC6925" stroke-width="15.1525" stroke-miterlimit="10" width="155.612" height="168.874"></rect>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 503 B |
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -1,8 +0,0 @@
|
||||
export default defineEventHandler((event) => {
|
||||
setHeader(event, "Content-Type", "text/plain; charset=utf-8");
|
||||
return [
|
||||
"User-agent: *",
|
||||
"Disallow: /",
|
||||
"",
|
||||
].join("\n");
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import Layout from './components/Layout';
|
||||
import Index from './pages/Index';
|
||||
import Mietkatalog from './pages/Mietkatalog';
|
||||
import EquipmentDetail from './pages/EquipmentDetail';
|
||||
import Kontakt from './pages/Kontakt';
|
||||
import Warenkorb from './pages/Warenkorb';
|
||||
import Admin from './pages/Admin';
|
||||
import Referenzen from './pages/Referenzen';
|
||||
import Impressum from './pages/Impressum';
|
||||
import Datenschutz from './pages/Datenschutz';
|
||||
import AGB from './pages/AGB';
|
||||
import ErrorPage from './pages/ErrorPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Index />} />
|
||||
<Route path="/mietkatalog" element={<Mietkatalog />} />
|
||||
<Route path="/mietkatalog/:id" element={<EquipmentDetail />} />
|
||||
<Route path="/kontakt" element={<Kontakt />} />
|
||||
<Route path="/warenkorb" element={<Warenkorb />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/referenzen" element={<Referenzen />} />
|
||||
<Route path="/impressum" element={<Impressum />} />
|
||||
<Route path="/datenschutz" element={<Datenschutz />} />
|
||||
<Route path="/agb-vermietung" element={<AGB />} />
|
||||
<Route path="*" element={<ErrorPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { apiPost } from './client';
|
||||
|
||||
export async function adminLogin(username: string, password: string): Promise<{ token: string }> {
|
||||
return apiPost<{ token: string }>('/admin/login', { username, password });
|
||||
}
|
||||
|
||||
export async function adminSync(token: string): Promise<{ status: string; message: string }> {
|
||||
return apiPost<{ status: string; message: string }>('/admin/sync', undefined, token);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
export async function apiGet<T>(url: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
||||
const query = new URLSearchParams();
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
query.set(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
const qs = query.toString();
|
||||
const fullUrl = `${API_BASE}${url}${qs ? '?' + qs : ''}`;
|
||||
const res = await fetch(fullUrl, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`API Error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiPost<T>(url: string, body?: unknown, token?: string): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch(`${API_BASE}${url}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) throw new Error(`API Error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiPut<T>(url: string, body?: unknown, token?: string): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch(`${API_BASE}${url}`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) throw new Error(`API Error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiDelete<T>(url: string, token?: string): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch(`${API_BASE}${url}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) throw new Error(`API Error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { apiGet } from './client';
|
||||
import type { EquipmentItem, EquipmentDetail, PaginatedEquipment, SortOption } from '../types';
|
||||
|
||||
export async function fetchEquipment(params: {
|
||||
search?: string;
|
||||
category?: string;
|
||||
sort?: SortOption;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}): Promise<PaginatedEquipment> {
|
||||
return apiGet<PaginatedEquipment>('/equipment', {
|
||||
search: params.search || undefined,
|
||||
category: params.category || undefined,
|
||||
sort: params.sort || 'name_asc',
|
||||
page: params.page || 1,
|
||||
page_size: params.page_size || 24,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchEquipmentDetail(id: number | string): Promise<EquipmentDetail> {
|
||||
return apiGet<EquipmentDetail>(`/equipment/${id}`);
|
||||
}
|
||||
|
||||
export async function fetchCategories(): Promise<string[]> {
|
||||
return apiGet<string[]>('/equipment/categories');
|
||||
}
|
||||
|
||||
export async function fetchEquipmentById(id: number | string): Promise<EquipmentItem> {
|
||||
return apiGet<EquipmentItem>(`/equipment/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
interface Props {
|
||||
icon?: string;
|
||||
title: string;
|
||||
message: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export default function EmptyState({ icon = '🔍', title, message, actionLabel, onAction }: Props) {
|
||||
return (
|
||||
<div className="text-center py-16 px-4" role="status">
|
||||
<div className="text-6xl mb-4" aria-hidden="true">{icon}</div>
|
||||
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text)' }}>{title}</h3>
|
||||
<p className="max-w-md mx-auto mb-6" style={{ color: 'var(--secondary)' }}>{message}</p>
|
||||
{actionLabel && <button onClick={onAction} className="hms-btn hms-btn-primary">{actionLabel}</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { EquipmentItem } from '../types';
|
||||
|
||||
interface Props {
|
||||
item: EquipmentItem & { code?: string; image?: string };
|
||||
onClick: (item: any) => void;
|
||||
onAddToCart: (item: any) => void;
|
||||
}
|
||||
|
||||
export default function EquipmentCard({ item, onClick, onAddToCart }: Props) {
|
||||
return (
|
||||
<div className="hms-eq-card" onClick={() => onClick(item)} role="article" tabIndex={0} onKeyDown={e => e.key === 'Enter' && onClick(item)}>
|
||||
<div className="aspect-[4/3] flex items-center justify-center relative overflow-hidden" style={{ background: 'var(--surface)' }}>
|
||||
{item.image ? (
|
||||
<img src={item.image} alt={item.name} loading="lazy" className="absolute inset-0 w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="text-4xl" style={{ color: 'var(--secondary)' }}>📦</div>
|
||||
)}
|
||||
{item.category && <span className="hms-badge hms-badge-primary absolute top-3 left-3">{item.category}</span>}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold text-sm mb-1 truncate" style={{ color: 'var(--text)' }}>{item.name}</h3>
|
||||
<p className="text-xs mb-3 line-clamp-2" style={{ color: 'var(--secondary)' }}>{item.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs" style={{ color: 'var(--secondary)' }}>{item.code}</span>
|
||||
<button onClick={e => { e.stopPropagation(); onAddToCart(item); }} className="hms-btn hms-btn-ghost text-xs px-3 py-1.5" style={{ color: 'var(--color-accent)' }} aria-label={item.name + ' zum Warenkorb hinzufügen'}>+ Hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
interface Props {
|
||||
title?: string;
|
||||
message: string;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export default function ErrorState({ title = 'Ein Fehler ist aufgetreten', message, onRetry }: Props) {
|
||||
return (
|
||||
<div className="text-center py-16 px-4" role="alert">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" style={{ background: 'var(--color-error-bg)', color: 'var(--color-error)' }}>
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text)' }}>{title}</h3>
|
||||
<p className="max-w-md mx-auto mb-6" style={{ color: 'var(--secondary)' }}>{message}</p>
|
||||
<button onClick={onRetry} className="hms-btn hms-btn-primary">Erneut versuchen</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import Logo from './Logo';
|
||||
|
||||
export default function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
return (
|
||||
<footer className="hms-footer mt-16" role="contentinfo">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4" style={{ color: 'var(--text)' }}>
|
||||
<Logo size={36} />
|
||||
<div><div className="font-bold text-sm">HMS Licht & Ton</div><div className="text-xs" style={{ color: 'var(--secondary)' }}>Veranstaltungstechnik</div></div>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed" style={{ color: 'var(--secondary)' }}>Hammerschmidt u. Mössle GbR – seit über 20 Jahren Ihr Partner für professionelle Veranstaltungstechnik in der Region Leipheim / Ellzee.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Navigation</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><Link to="/" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Home</Link></li>
|
||||
<li><Link to="/referenzen" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Referenzen</Link></li>
|
||||
<li><Link to="/mietkatalog" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Mietkatalog</Link></li>
|
||||
<li><Link to="/kontakt" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Kontakt</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Kontakt</h3>
|
||||
<ul className="space-y-2 text-sm" style={{ color: 'var(--secondary)' }}>
|
||||
<li>Grockelhofen 10, 89340 Leipheim</li>
|
||||
<li><a href="tel:+498221204433" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">+49 (0) 8221 / 204433</a></li>
|
||||
<li><a href="mailto:info@hms-licht-ton.de" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">info@hms-licht-ton.de</a></li>
|
||||
<li className="flex gap-3 pt-2">
|
||||
<a href="https://facebook.com" target="_blank" rel="noopener" aria-label="Facebook" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]"><svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg></a>
|
||||
<a href="https://instagram.com" target="_blank" rel="noopener" aria-label="Instagram" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]"><svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Rechtliches</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><Link to="/impressum" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Impressum</Link></li>
|
||||
<li><Link to="/datenschutz" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">DSGVO</Link></li>
|
||||
<li><Link to="/agb-vermietung" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">AGB Vermietung</Link></li>
|
||||
<li><Link to="/admin" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)] text-xs">Admin-Login</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t mt-8 pt-6 text-center text-xs" style={{ borderColor: 'var(--border)', color: 'var(--secondary)' }}>
|
||||
© {year} Hammerschmidt u. Mössle GbR · Veranstaltungstechnik · Leipheim / Ellzee
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import Logo from './Logo';
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Home', to: '/' },
|
||||
{ label: 'Referenzen', to: '/referenzen' },
|
||||
{ label: 'Mietkatalog', to: '/mietkatalog' },
|
||||
{ label: 'Kontakt', to: '/kontakt' }
|
||||
];
|
||||
|
||||
export default function Header() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
function isActive(to: string): boolean {
|
||||
if (to === '/') return location.pathname === '/';
|
||||
return location.pathname.startsWith(to);
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="hms-header" role="banner">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between" style={{ height: 'var(--header-height)' }}>
|
||||
<Link to="/" className="flex items-center gap-2" style={{ color: 'var(--text)' }} aria-label="HMS Licht & Ton Startseite">
|
||||
<Logo size={40} />
|
||||
<div className="hidden sm:block leading-tight">
|
||||
<div className="font-bold text-base" style={{ color: 'var(--text)' }}>HMS Licht & Ton</div>
|
||||
<div className="text-xs" style={{ color: 'var(--secondary)' }}>Veranstaltungstechnik</div>
|
||||
</div>
|
||||
</Link>
|
||||
<nav className="hidden md:flex items-center gap-1" role="navigation" aria-label="Hauptnavigation">
|
||||
{navItems.map(item => (
|
||||
<Link key={item.to} to={item.to}
|
||||
className="hms-nav-btn px-4 py-2 rounded-lg text-sm font-medium"
|
||||
style={isActive(item.to) ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }}
|
||||
aria-current={isActive(item.to) ? 'page' : undefined}>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<Link to="/warenkorb" className="hms-btn hms-btn-primary text-sm ml-2 px-4 py-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="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>
|
||||
Warenkorb
|
||||
</Link>
|
||||
</nav>
|
||||
<button onClick={() => setMobileMenuOpen(!mobileMenuOpen)} className="md:hidden p-2 rounded-lg" style={{ color: 'var(--text-muted)' }} aria-expanded={mobileMenuOpen} aria-label="Menü öffnen/schließen">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{!mobileMenuOpen ?
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"/> :
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{mobileMenuOpen && (
|
||||
<nav id="mobile-menu" className="md:hidden border-t" style={{ borderColor: 'var(--border)', background: 'var(--panel)' }} role="navigation" aria-label="Mobile Navigation">
|
||||
<div className="px-4 py-3 space-y-1">
|
||||
{navItems.map(item => (
|
||||
<Link key={item.to} to={item.to} onClick={() => setMobileMenuOpen(false)}
|
||||
className="hms-nav-btn block w-full text-left px-4 py-3 rounded-lg text-sm font-medium"
|
||||
style={isActive(item.to) ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }}>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<Link to="/warenkorb" onClick={() => setMobileMenuOpen(false)} className="block w-full text-left px-4 py-3 rounded-lg text-sm font-medium text-white" style={{ background: 'var(--color-accent)' }}>🛒 Warenkorb</Link>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<a href="#main-content" className="skip-link">Zum Hauptinhalt springen</a>
|
||||
<Header />
|
||||
<main id="main-content" className="flex-1" role="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
content?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function LegalContentPage({ title, content = '', loading = false }: Props) {
|
||||
useEffect(() => {
|
||||
document.title = `${title} – HMS Licht & Ton`;
|
||||
}, [title]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="space-y-4">
|
||||
<div className="hms-skeleton rounded-md" style={{ height: 32, width: '60%' }} />
|
||||
<div className="hms-skeleton rounded-md" style={{ height: 20 }} />
|
||||
<div className="hms-skeleton rounded-md" style={{ height: 20, width: '90%' }} />
|
||||
<div className="hms-skeleton rounded-md" style={{ height: 20 }} />
|
||||
<div className="hms-skeleton rounded-md" style={{ height: 20, width: '80%' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold text-text mb-6">{title}</h1>
|
||||
<div className="prose prose-invert max-w-none text-text-muted" dangerouslySetInnerHTML={{ __html: content }} />
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
interface Props {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export default function LoadingSkeleton({ count = 6 }: Props) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6" aria-live="polite" aria-busy="true">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="hms-card overflow-hidden">
|
||||
<div className="hms-skeleton aspect-[4/3]" style={{ borderRadius: 0 }}></div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="hms-skeleton h-4 w-3/4"></div>
|
||||
<div className="hms-skeleton h-3 w-full"></div>
|
||||
<div className="hms-skeleton h-3 w-1/2"></div>
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div className="hms-skeleton h-3 w-20"></div>
|
||||
<div className="hms-skeleton h-6 w-24" style={{ borderRadius: 'var(--radius-md)' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
interface LogoProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export default function Logo({ size = 40 }: LogoProps) {
|
||||
return (
|
||||
<svg className="hms-logo-svg" viewBox="0 0 200 200" style={{ height: `${size}px`, width: 'auto' }} aria-label="HMS Licht & Ton Logo" role="img">
|
||||
<path fill="currentColor" d="M166.266,172.872h-37.687v-60.718H74.226v60.718H36.539V26.956h37.687v56.335h54.354V26.956h37.687V172.872z" />
|
||||
<rect x="23.598" y="15.343" fill="none" stroke="#FA5C01" strokeWidth="15.1525" strokeMiterlimit="10" width="155.612" height="168.874" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
interface Props {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function ServiceCard({ icon, title, description }: Props) {
|
||||
return (
|
||||
<div className="hms-card p-6">
|
||||
<div className="hms-icon-circle mb-4"><span aria-hidden="true">{icon}</span></div>
|
||||
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>{title}</h3>
|
||||
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-muted)' }}>{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
<template>
|
||||
<span class="hms-speaker-icon-wrap" v-html="svgStr"></span>
|
||||
</template>
|
||||
import { useMemo } from 'react';
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ type?: string }>(), { type: 'linearray' })
|
||||
interface Props {
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
linearray: '<rect x="8" y="2" width="16" height="40" rx="2"/><circle cx="16" cy="10" r="3"/><circle cx="16" cy="22" r="4"/><circle cx="16" cy="34" r="3"/>',
|
||||
@@ -14,11 +11,12 @@ const icons: Record<string, string> = {
|
||||
pointsource: '<circle cx="16" cy="16" r="12"/><circle cx="16" cy="16" r="6"/><circle cx="16" cy="16" r="2"/>',
|
||||
column: '<rect x="10" y="2" width="12" height="44" rx="2"/><circle cx="16" cy="8" r="2"/><circle cx="16" cy="16" r="2"/><circle cx="16" cy="24" r="2"/><circle cx="16" cy="32" r="2"/><circle cx="16" cy="40" r="2"/>',
|
||||
movinghead: '<rect x="10" y="4" width="12" height="16" rx="2"/><path d="M16 20v8"/><rect x="8" y="28" width="16" height="4" rx="1"/>'
|
||||
}
|
||||
};
|
||||
|
||||
const svgStr = computed(() =>
|
||||
'<svg class="hms-speaker-icon" viewBox="0 0 32 48" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">' +
|
||||
(icons[props.type] || icons.linearray) +
|
||||
'</svg>'
|
||||
)
|
||||
</script>
|
||||
export default function SpeakerIcon({ type = 'linearray' }: Props) {
|
||||
const svgStr = useMemo(() =>
|
||||
'<svg class="hms-speaker-icon" viewBox="0 0 32 48" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">' +
|
||||
(icons[type] || icons.linearray) +
|
||||
'</svg>', [type]);
|
||||
return <span className="hms-speaker-icon-wrap" dangerouslySetInnerHTML={{ __html: svgStr }} />;
|
||||
}
|
||||
@@ -160,7 +160,7 @@ body {
|
||||
.hms-logo-svg { height: 40px; width: auto; }
|
||||
.hms-logo-svg rect { stroke: var(--color-accent) !important; }
|
||||
|
||||
/* Component: Hero – with image background, subtle orange */
|
||||
/* Component: Hero */
|
||||
.hms-hero {
|
||||
position: relative;
|
||||
background: var(--bg);
|
||||
@@ -193,7 +193,7 @@ body {
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
/* Component: Button – orange only on primary, subtle */
|
||||
/* Component: Button */
|
||||
.hms-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
@@ -235,7 +235,7 @@ body {
|
||||
}
|
||||
.hms-btn-ghost:hover { background: var(--surface); color: var(--color-accent); border-color: var(--color-accent-border); }
|
||||
|
||||
/* Component: Nav Button (header menu items) */
|
||||
/* Component: Nav Button */
|
||||
.hms-nav-btn {
|
||||
position: relative;
|
||||
transition: color var(--transition-fast), background-color var(--transition-fast), border-color var(--transition-fast) !important;
|
||||
@@ -258,7 +258,7 @@ body {
|
||||
background: var(--color-accent-light) !important;
|
||||
}
|
||||
|
||||
/* Component: Badge – subtle orange */
|
||||
/* Component: Badge */
|
||||
.hms-badge {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 2px var(--space-sm);
|
||||
@@ -318,7 +318,7 @@ select.hms-input option { background: var(--panel); color: var(--text); }
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Component: Service Icon Circle – subtle, no large orange area */
|
||||
/* Component: Service Icon Circle */
|
||||
.hms-icon-circle {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
@@ -407,7 +407,7 @@ select.hms-input option { background: var(--panel); color: var(--text); }
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Section divider – subtle */
|
||||
/* Section divider */
|
||||
.hms-section-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent 0%, var(--border) 50%, transparent 100%);
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import { CartProvider } from './store/CartContext';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<CartProvider>
|
||||
<App />
|
||||
</CartProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -1,12 +1,4 @@
|
||||
<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" }],
|
||||
});
|
||||
import LegalContentPage from '../components/LegalContentPage';
|
||||
|
||||
const content = `
|
||||
<h2>§ 1 Geltungsbereich</h2>
|
||||
@@ -105,4 +97,7 @@ const content = `
|
||||
Zweck der unwirksamen Bestimmung am nächsten kommt.
|
||||
</p>
|
||||
`;
|
||||
</script>
|
||||
|
||||
export default function AGB() {
|
||||
return <LegalContentPage title="AGB Vermietung" content={content} />;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import Logo from '../components/Logo';
|
||||
|
||||
export default function Admin() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
|
||||
function login(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!username || !password) {
|
||||
setError('Bitte Benutzername und Passwort eingeben');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
setLoggedIn(true);
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto px-4 py-16">
|
||||
<div className="hms-card p-8">
|
||||
<div className="text-center mb-6">
|
||||
<Logo size={48} />
|
||||
<h1 className="text-xl font-bold mt-4" style={{ color: 'var(--text)' }}>Admin-Login</h1>
|
||||
<p className="text-sm mt-1" style={{ color: 'var(--secondary)' }}>Equipment-Sync Verwaltungsbereich</p>
|
||||
</div>
|
||||
{loggedIn ? (
|
||||
<div className="text-center py-8" role="status">
|
||||
<div className="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 className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"/></svg>
|
||||
</div>
|
||||
<p className="font-medium" style={{ color: 'var(--text)' }}>Eingeloggt</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--secondary)' }}>(Prototyp – keine echte Session)</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={login} noValidate>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="admin-user" className="block text-sm font-medium mb-1" style={{ color: 'var(--text-muted)' }}>Benutzername</label>
|
||||
<input id="admin-user" value={username} onChange={e => setUsername(e.target.value)} type="text" className="hms-input" placeholder="admin" aria-invalid={!!error} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="admin-pass" className="block text-sm font-medium mb-1" style={{ color: 'var(--text-muted)' }}>Passwort</label>
|
||||
<input id="admin-pass" value={password} onChange={e => setPassword(e.target.value)} type="password" className="hms-input" placeholder="••••••••" aria-invalid={!!error} />
|
||||
</div>
|
||||
{error && <p className="text-xs" style={{ color: 'var(--color-error)' }} role="alert">{error}</p>}
|
||||
<button type="submit" disabled={loading} className="hms-btn hms-btn-primary w-full py-3">
|
||||
{loading ? <span className="hms-spinner" style={{ width: 18, height: 18, borderWidth: 2 }} /> : <span>Einloggen</span>}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,4 @@
|
||||
<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" }],
|
||||
});
|
||||
import LegalContentPage from '../components/LegalContentPage';
|
||||
|
||||
const content = `
|
||||
<h2>1. Datenschutz auf einen Blick</h2>
|
||||
@@ -105,4 +97,7 @@ const content = `
|
||||
personenbezogenen Daten zu beschweren.
|
||||
</p>
|
||||
`;
|
||||
</script>
|
||||
|
||||
export default function Datenschutz() {
|
||||
return <LegalContentPage title="Datenschutz (DSGVO)" content={content} />;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { fetchEquipmentDetail } from '../api/equipment';
|
||||
import type { EquipmentDetail } from '../types';
|
||||
import { useCart } from '../store/CartContext';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
|
||||
export default function EquipmentDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { addItemByFields } = useCart();
|
||||
const [item, setItem] = useState<EquipmentDetail | null>(null);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [rentalStart, setRentalStart] = useState('');
|
||||
const [rentalEnd, setRentalEnd] = useState('');
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
setPending(true);
|
||||
setError(false);
|
||||
if (id) {
|
||||
fetchEquipmentDetail(id).then(data => {
|
||||
if (mountedRef.current) { setItem(data); setPending(false); }
|
||||
}).catch(() => {
|
||||
if (mountedRef.current) { setError(true); setPending(false); }
|
||||
});
|
||||
}
|
||||
return () => { mountedRef.current = false; };
|
||||
}, [id]);
|
||||
|
||||
function navigateTo(route: string) {
|
||||
navigate(route);
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function addToCart() {
|
||||
if (!item) return;
|
||||
addItemByFields({
|
||||
equipment_id: item.id,
|
||||
name: item.name,
|
||||
rental_price: null,
|
||||
image_url: item.image_url || null,
|
||||
});
|
||||
navigateTo('/warenkorb');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<nav className="text-sm mb-6" aria-label="Breadcrumb">
|
||||
<Link to="/mietkatalog" style={{ color: 'var(--secondary)' }}>← Zurück zum Katalog</Link>
|
||||
</nav>
|
||||
{pending && (
|
||||
<div className="grid lg:grid-cols-2 gap-8" aria-live="polite" aria-busy="true">
|
||||
<div className="hms-skeleton aspect-square rounded-lg"></div>
|
||||
<div className="space-y-4">
|
||||
<div className="hms-skeleton h-8 w-3/4"></div>
|
||||
<div className="hms-skeleton h-4 w-1/4"></div>
|
||||
<div className="hms-skeleton h-24 w-full"></div>
|
||||
<div className="hms-skeleton h-32 w-full"></div>
|
||||
<div className="hms-skeleton h-12 w-full" style={{ borderRadius: 'var(--radius-md)' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <ErrorState title="Gerät nicht gefunden" message="Das angeforderte Gerät konnte nicht gefunden werden." onRetry={() => navigateTo('/mietkatalog')} />}
|
||||
{!pending && !error && item && (
|
||||
<div>
|
||||
<div className="grid lg:grid-cols-2 gap-8 lg:gap-12">
|
||||
<div className="aspect-square rounded-lg flex items-center justify-center relative overflow-hidden border" style={{ background: 'var(--surface)', borderColor: 'var(--border)' }}>
|
||||
{item.image_url ? (
|
||||
<img src={item.image_url} alt={item.name} className="absolute inset-0 w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<div className="text-8xl mb-2" style={{ color: 'var(--secondary)' }}>📦</div>
|
||||
<div className="text-sm" style={{ color: 'var(--secondary)' }}>Kein Bild verfügbar</div>
|
||||
</div>
|
||||
)}
|
||||
{item.category && <span className="hms-badge hms-badge-primary absolute top-4 left-4 text-sm">{item.category}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm mb-1" style={{ color: 'var(--secondary)' }}>Artikelnummer: {item.number || item.rentman_id}</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold mb-2" style={{ color: 'var(--text)' }}>{item.name}</h1>
|
||||
{item.brand && <div className="text-sm mb-4" style={{ color: 'var(--text-muted)' }}>Marke: <span className="font-medium" style={{ color: 'var(--text)' }}>{item.brand}</span></div>}
|
||||
{item.description && <p className="leading-relaxed mb-6" style={{ color: 'var(--text-muted)' }}>{item.description}</p>}
|
||||
{item.specifications && (
|
||||
<div className="hms-card p-4 mb-6">
|
||||
<h2 className="text-sm font-semibold mb-3" style={{ color: 'var(--text)' }}>Technische Daten</h2>
|
||||
<dl className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||
{Object.entries(item.specifications).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between py-2 text-sm" style={{ borderColor: 'var(--border)' }}>
|
||||
<dt className="capitalize" style={{ color: 'var(--secondary)' }}>{key.replace(/_/g, ' ')}</dt>
|
||||
<dd className="font-medium text-right" style={{ color: 'var(--text)' }}>{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<div className="hms-card p-6">
|
||||
<h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Mietanfrage</h2>
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<div>
|
||||
<label htmlFor="rental-start" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Mietbeginn</label>
|
||||
<input id="rental-start" value={rentalStart} onChange={e => setRentalStart(e.target.value)} type="date" className="hms-input text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="rental-end" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Mietende</label>
|
||||
<input id="rental-end" value={rentalEnd} onChange={e => setRentalEnd(e.target.value)} type="date" className="hms-input text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="quantity" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Anzahl</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => setQuantity(q => Math.max(1, q - 1))} className="hms-btn hms-btn-secondary px-3 py-2" aria-label="Anzahl verringern">−</button>
|
||||
<input id="quantity" value={quantity} onChange={e => setQuantity(Math.max(1, Number(e.target.value)))} type="number" min={1} className="hms-input text-center w-20" aria-label="Anzahl" />
|
||||
<button onClick={() => setQuantity(q => q + 1)} className="hms-btn hms-btn-secondary px-3 py-2" aria-label="Anzahl erhöhen">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={addToCart} className="hms-btn hms-btn-primary w-full py-3 text-base">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="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 className="text-xs mt-3 text-center" style={{ color: 'var(--secondary)' }}>Preise auf Anfrage – unverbindliche Mietanfrage</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import Header from '../components/Header';
|
||||
import Footer from '../components/Footer';
|
||||
|
||||
export default function ErrorPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main id="main-content" className="flex-1" role="main">
|
||||
<div className="max-w-md mx-auto px-4 py-24 text-center">
|
||||
<div className="text-8xl font-bold mb-4" style={{ color: 'var(--secondary)' }}>404</div>
|
||||
<h1 className="text-2xl font-semibold mb-2" style={{ color: 'var(--text)' }}>Seite nicht gefunden</h1>
|
||||
<p className="mb-8" style={{ color: 'var(--secondary)' }}>Die angeforderte Seite existiert nicht.</p>
|
||||
<Link to="/" className="hms-btn hms-btn-primary">Zur Startseite</Link>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,4 @@
|
||||
<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" }],
|
||||
});
|
||||
import LegalContentPage from '../components/LegalContentPage';
|
||||
|
||||
const impressumContent = `
|
||||
<h2>Angaben gemäß § 5 TMG</h2>
|
||||
@@ -76,4 +68,7 @@ const impressumContent = `
|
||||
jeweiligen Autors bzw. Erstellers.
|
||||
</p>
|
||||
`;
|
||||
</script>
|
||||
|
||||
export default function Impressum() {
|
||||
return <LegalContentPage title="Impressum" content={impressumContent} />;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import SpeakerIcon from '../components/SpeakerIcon';
|
||||
import ServiceCard from '../components/ServiceCard';
|
||||
|
||||
const services = [
|
||||
{ icon: '\u{1F50A}', title: 'Vermietung', description: 'Lautsprecher, Mischpulte, Lichtanlagen, Rigging und Komplett-Setups aus unserem 1.000+ Geräte umfassenden Mietlager in Ellzee. Geprüftes Equipment, sofort einsatzbereit.' },
|
||||
{ icon: '\u{1F6D2}', title: 'Verkauf', description: 'Neu- und Gebrauchtgeräte führender Hersteller mit Beratung, Inbetriebnahme und Service. Wir liefern nicht nur – wir konfigurieren Ihre Anlage einsatzfertig.' },
|
||||
{ icon: '\u{1F477}', title: 'Personal', description: 'Qualifizierte Tontechniker, Lichttechniker und Rigger für Aufbau, Betrieb und Abbau. Mit TÜV-geprüfter Ausbildung und umfangreicher Praxiserfahrung.' },
|
||||
{ icon: '\u{1F69A}', title: 'Transport', description: 'Eigene Transportfahrzeuge für sichere An- und Ablieferung. Inklusive Verladekonzept, Positionierung und Rückholung – kundengerecht terminiert.' },
|
||||
{ icon: '\u{1F4E6}', title: 'Lagerung', description: 'Klimatisierte und trockene Lagerung für Equipment und Veranstaltungsbestände. Mit Bestandsmanagement und regelmäßiger Funktionsprüfung.' },
|
||||
{ icon: '\u{1F527}', title: 'Werkstatt', description: 'Herstellerübergreifende Reparatur und Wartung in unserer eigenen Werkstatt. Regelmäßige DGUV-Prüfungen und Kalibrierung für Ihren sicheren Betrieb.' },
|
||||
{ icon: '\u26A1', title: 'Installation', description: 'Festinstallationen in Schaufenstern, Lokalen und Veranstaltungsstätten. Von der Planung über die Elektroinstallation bis zur Abnahme – alles aus einer Hand.' },
|
||||
{ icon: '\u{1F4C5}', title: 'Booking', description: 'Vermittlung von Künstlern und Technik-Personal für Ihre Veranstaltung. Mit unserem branchenweiten Netzwerk finden wir die passenden Professionals für Ihr Event.' }
|
||||
];
|
||||
|
||||
const speakerIcons = ['linearray', 'subwoofer', 'pointsource', 'column', 'monitor', 'movinghead'];
|
||||
|
||||
interface EquipmentItem {
|
||||
id: number;
|
||||
name: string;
|
||||
number: string | null;
|
||||
category: string | null;
|
||||
image_url: string | null;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
const navigate = useNavigate();
|
||||
const [highlights, setHighlights] = useState<EquipmentItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/equipment?page_size=6&sort=name_asc')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setHighlights(data.items || []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function goTo(route: string) {
|
||||
navigate(route);
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="hms-hero py-20 sm:py-32" aria-labelledby="hero-title">
|
||||
<div className="hms-hero-bg" style={{ backgroundImage: "url('https://images.unsplash.com/photo-1470229722913-7c0e2dbbafd3?w=1920&q=80')" }}></div>
|
||||
<div className="hms-hero-overlay"></div>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div className="max-w-2xl">
|
||||
<span className="hms-badge hms-badge-primary mb-4">Seit 2003 · Über 20 Jahre Veranstaltungstechnik</span>
|
||||
<h1 id="hero-title" className="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight mb-6" style={{ color: 'var(--text)' }}>
|
||||
Professionelle Technik<br /><span style={{ color: 'var(--color-accent)' }}>für Ihre Veranstaltung</span>
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl mb-8 max-w-xl" style={{ color: 'var(--text-muted)' }}>
|
||||
Von der Firmenevent-Beschallung bis zur Open-Air-Großproduktion: HMS Licht & Ton liefert Tontechnik, Lichttechnik, Rigging und Komplettlösungen aus Leipheim und Ellzee.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<button onClick={() => goTo('/mietkatalog')} className="hms-btn hms-btn-primary text-base px-8 py-4">
|
||||
Mietkatalog öffnen
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" /></svg>
|
||||
</button>
|
||||
<button onClick={() => goTo('/kontakt')} className="hms-btn hms-btn-secondary text-base px-8 py-4">Beratung anfragen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-12 sm:py-16" style={{ background: 'var(--bg)' }} aria-labelledby="speaker-title">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<span className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--color-accent)' }}>Equipment-Highlights</span>
|
||||
<h2 id="speaker-title" className="text-2xl sm:text-3xl font-bold mt-1" style={{ color: 'var(--text)' }}>Lautsprecher & Strahler aus unserem Mietlager</h2>
|
||||
</div>
|
||||
<div className="hms-speaker-grid">
|
||||
{loading ? (
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="hms-speaker-item">
|
||||
<div className="hms-skeleton h-12 w-12 rounded mx-auto mb-2"></div>
|
||||
<div className="hms-skeleton h-4 w-3/4 mx-auto mb-1"></div>
|
||||
<div className="hms-skeleton h-3 w-1/2 mx-auto"></div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
highlights.map((item, i) => (
|
||||
<div key={item.id} className="hms-speaker-item" onClick={() => goTo('/mietkatalog')} role="button" tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && goTo('/mietkatalog')}>
|
||||
<SpeakerIcon type={speakerIcons[i % speakerIcons.length]} />
|
||||
<div className="hms-speaker-name">{item.name}</div>
|
||||
<div className="hms-speaker-type">{item.category || 'Equipment'}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16 sm:py-20" style={{ background: 'var(--panel)' }} aria-labelledby="about-title">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<span className="text-sm font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--color-accent)' }}>Unternehmen</span>
|
||||
<h2 id="about-title" className="text-3xl sm:text-4xl font-bold mb-6" style={{ color: 'var(--text)' }}>Ihr Technik-Partner mit Erfahrung</h2>
|
||||
<p className="leading-relaxed mb-4" style={{ color: 'var(--text-muted)' }}>Die <strong style={{ color: 'var(--text)' }}>Hammerschmidt u. Mössle GbR</strong> ist seit 2003 Ihr zuverlässiger Partner für Veranstaltungstechnik in der Region Leipheim / Ellzee und im gesamten süddeutschen Raum.</p>
|
||||
<p className="leading-relaxed mb-4" style={{ color: 'var(--text-muted)' }}>Mit einem Mietlager von über 1.000 Geräten, einer eigenen Werkstatt und erfahrenem Personal realisieren wir Veranstaltungen jeder Größenordnung.</p>
|
||||
<div className="grid grid-cols-3 gap-4 mt-8">
|
||||
<div className="text-center"><div className="text-3xl font-bold" style={{ color: 'var(--color-accent)' }}>20+</div><div className="text-sm" style={{ color: 'var(--secondary)' }}>Jahre Erfahrung</div></div>
|
||||
<div className="text-center"><div className="text-3xl font-bold" style={{ color: 'var(--color-accent)' }}>1.000+</div><div className="text-sm" style={{ color: 'var(--secondary)' }}>Geräte im Lager</div></div>
|
||||
<div className="text-center"><div className="text-3xl font-bold" style={{ color: 'var(--color-accent)' }}>2</div><div className="text-sm" style={{ color: 'var(--secondary)' }}>Standorte</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="aspect-[4/3] rounded-lg overflow-hidden shadow-xl">
|
||||
<img src="https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=800&q=80" alt="Veranstaltungstechnik Setup" loading="lazy" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16 sm:py-20" style={{ background: 'var(--bg)' }} aria-labelledby="services-title">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-sm font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--color-accent)' }}>Leistungen</span>
|
||||
<h2 id="services-title" className="text-3xl sm:text-4xl font-bold" style={{ color: 'var(--text)' }}>Von der Vermietung bis zur Komplettbetreuung</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{services.map((s) => (
|
||||
<ServiceCard key={s.title} icon={s.icon} title={s.title} description={s.description} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16 sm:py-20" style={{ background: 'var(--panel)' }} aria-labelledby="rental-title">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="rounded-lg p-8 sm:p-12 lg:p-16 text-center" style={{ background: 'var(--bg)', border: '1px solid var(--border)' }}>
|
||||
<h2 id="rental-title" className="text-3xl sm:text-4xl font-bold mb-4" style={{ color: 'var(--text)' }}>Online-Mietkatalog</h2>
|
||||
<p className="max-w-2xl mx-auto mb-8" style={{ color: 'var(--text-muted)' }}>Durchsuchen Sie unser Equipment-Sortiment und stellen Sie Ihre Mietanfrage direkt online zusammen.</p>
|
||||
<button onClick={() => goTo('/mietkatalog')} className="hms-btn hms-btn-primary text-base px-8 py-4">
|
||||
Katalog durchsuchen
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function Kontakt() {
|
||||
const [form, setForm] = useState({ name: '', email: '', phone: '', subject: '', message: '', privacy: false });
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(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';
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
}
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
setSubmitting(true);
|
||||
setTimeout(() => { setSubmitting(false); setSubmitted(true); }, 1500);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setForm({ name: '', email: '', phone: '', subject: '', message: '', privacy: false });
|
||||
setSubmitted(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold mb-2" style={{ color: 'var(--text)' }}>Kontakt</h1>
|
||||
<p className="mb-8" style={{ color: 'var(--secondary)' }}>Wir beraten Sie persönlich – telefonisch, per E-Mail oder über das Kontaktformular.</p>
|
||||
<div className="grid lg:grid-cols-2 gap-8">
|
||||
<div className="space-y-6">
|
||||
<div className="hms-card p-6">
|
||||
<h2 className="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 className="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" className="inline-flex items-center gap-1 mt-2" style={{ color: 'var(--color-accent)' }}>Route planen <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg></a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hms-card p-6">
|
||||
<h2 className="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 className="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" className="inline-flex items-center gap-1 mt-2" style={{ color: 'var(--color-accent)' }}>Route planen <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg></a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hms-card p-6">
|
||||
<h2 className="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 className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
<div className="flex justify-between py-2 border-b" style={{ borderColor: 'var(--border)' }}><span>Montag – Freitag</span><span className="font-medium" style={{ color: 'var(--text)' }}>10:00 – 18:00</span></div>
|
||||
<div className="flex justify-between py-2"><span>Samstag & Sonntag</span><span style={{ color: 'var(--secondary)' }}>Geschlossen</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div className="hms-card p-6 text-center">
|
||||
<div className="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 className="font-semibold" style={{ color: 'var(--text)' }}>Leopold Hammerschmidt</h3>
|
||||
<div className="text-xs mb-3" style={{ color: 'var(--secondary)' }}>Geschäftsführung</div>
|
||||
<a href="tel:+491726264796" className="block text-sm mb-1" style={{ color: 'var(--text-muted)' }}>+49 (0) 172 6264796</a>
|
||||
<a href="mailto:leopold.hammerschmidt@hms-licht-ton.de" className="block text-sm break-all" style={{ color: 'var(--text-muted)' }}>leopold.hammerschmidt@hms-licht-ton.de</a>
|
||||
</div>
|
||||
<div className="hms-card p-6 text-center">
|
||||
<div className="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 className="font-semibold" style={{ color: 'var(--text)' }}>Andreas Mössle</h3>
|
||||
<div className="text-xs mb-3" style={{ color: 'var(--secondary)' }}>Geschäftsführung</div>
|
||||
<a href="tel:+491739014604" className="block text-sm mb-1" style={{ color: 'var(--text-muted)' }}>+49 (0) 173 / 9014604</a>
|
||||
<a href="mailto:andreas.moessle@hms-licht-ton.de" className="block text-sm break-all" style={{ color: 'var(--text-muted)' }}>andreas.moessle@hms-licht-ton.de</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="hms-card p-6 sm:p-8">
|
||||
<h2 className="text-xl font-semibold mb-6" style={{ color: 'var(--text)' }}>Nachricht senden</h2>
|
||||
{submitted ? (
|
||||
<div className="text-center py-8" role="status">
|
||||
<div className="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 className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"/></svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>Nachricht übermittelt</h3>
|
||||
<p className="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 onClick={resetForm} className="hms-btn hms-btn-secondary">Neue Nachricht</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} noValidate>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="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" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} type="text" className={`hms-input ${errors.name ? 'hms-input-error' : ''}`} placeholder="Ihr Name" aria-invalid={!!errors.name} />
|
||||
{errors.name && <p className="text-xs mt-1" style={{ color: 'var(--color-error)' }} role="alert">{errors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="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" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} type="email" className={`hms-input ${errors.email ? 'hms-input-error' : ''}`} placeholder="ihre@email.de" aria-invalid={!!errors.email} />
|
||||
{errors.email && <p className="text-xs mt-1" style={{ color: 'var(--color-error)' }} role="alert">{errors.email}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium mb-1" style={{ color: 'var(--text-muted)' }}>Telefon</label>
|
||||
<input id="phone" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} type="tel" className="hms-input" placeholder="+49 ..." />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-sm font-medium mb-1" style={{ color: 'var(--text-muted)' }}>Anliegen</label>
|
||||
<select id="subject" value={form.subject} onChange={e => setForm(f => ({ ...f, subject: e.target.value }))} className="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 htmlFor="message" className="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" value={form.message} onChange={e => setForm(f => ({ ...f, message: e.target.value }))} rows={5} className={`hms-input ${errors.message ? 'hms-input-error' : ''}`} placeholder="Beschreiben Sie Ihr Anliegen – Veranstaltungstyp, Ort, erwartete Besucherzahl, benötigtes Equipment..." aria-invalid={!!errors.message} />
|
||||
{errors.message && <p className="text-xs mt-1" style={{ color: 'var(--color-error)' }} role="alert">{errors.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" checked={form.privacy} onChange={e => setForm(f => ({ ...f, privacy: e.target.checked }))} className="mt-1 w-5 h-5 rounded" style={{ accentColor: 'var(--color-accent)' }} aria-invalid={!!errors.privacy} />
|
||||
<span className="text-xs" style={{ color: 'var(--text-muted)' }}>Ich habe die <Link to="/datenschutz" style={{ color: 'var(--color-accent)' }}>Datenschutzerklärung</Link> gelesen und stimme zu, dass meine Angaben zur Bearbeitung meiner Anfrage gespeichert werden. <span style={{ color: 'var(--color-error)' }}>*</span></span>
|
||||
</label>
|
||||
{errors.privacy && <p className="text-xs mt-1" style={{ color: 'var(--color-error)' }} role="alert">{errors.privacy}</p>}
|
||||
</div>
|
||||
<button type="submit" disabled={submitting} className="hms-btn hms-btn-primary w-full text-base py-3">
|
||||
{submitting ? <span className="hms-spinner" style={{ width: 18, height: 18, borderWidth: 2 }} /> : <span>Nachricht senden</span>}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { fetchEquipment, fetchCategories } from '../api/equipment';
|
||||
import type { EquipmentItem, SortOption, PaginatedEquipment } from '../types';
|
||||
import { useCart } from '../store/CartContext';
|
||||
import EquipmentCard from '../components/EquipmentCard';
|
||||
import LoadingSkeleton from '../components/LoadingSkeleton';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
|
||||
export default function Mietkatalog() {
|
||||
const navigate = useNavigate();
|
||||
const { addItemByFields } = useCart();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState('alle');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('name_asc');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [categories, setCategories] = useState<string[]>(['alle']);
|
||||
const [data, setData] = useState<PaginatedEquipment | null>(null);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setPending(true);
|
||||
setError(false);
|
||||
try {
|
||||
const result = await fetchEquipment({
|
||||
search: searchQuery.trim() || undefined,
|
||||
category: activeCategory !== 'alle' ? activeCategory : undefined,
|
||||
sort: sortBy,
|
||||
page: currentPage,
|
||||
page_size: 24,
|
||||
});
|
||||
if (mountedRef.current) {
|
||||
setData(result);
|
||||
setPending(false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mountedRef.current) {
|
||||
setError(true);
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
}, [searchQuery, activeCategory, sortBy, currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
load();
|
||||
return () => { mountedRef.current = false; };
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories().then(cats => {
|
||||
if (cats && cats.length > 0 && mountedRef.current) {
|
||||
setCategories(['alle', ...cats.filter(c => c && c.trim() !== '')]);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
function onSearchInput() {
|
||||
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||||
searchTimeout.current = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
load();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function reload() {
|
||||
load();
|
||||
}
|
||||
|
||||
function navigateTo(route: string) {
|
||||
navigate(route);
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function navigateToDetail(item: any) {
|
||||
navigate('/mietkatalog/' + item.id);
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
function mapItem(item: EquipmentItem) {
|
||||
return {
|
||||
...item,
|
||||
code: item.number || item.rentman_id,
|
||||
image: item.image_url || '',
|
||||
};
|
||||
}
|
||||
|
||||
const equipment = data?.items || [];
|
||||
const total = data?.total || 0;
|
||||
const totalPages = data?.total_pages || 0;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="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 onClick={() => navigateTo('/warenkorb')} className="hms-btn hms-btn-secondary text-sm self-start sm:self-auto">🛒 Warenkorb ansehen</button>
|
||||
</div>
|
||||
<div className="hms-card p-4 mb-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<svg className="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 strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
<input value={searchQuery} onChange={e => { setSearchQuery(e.target.value); onSearchInput(); }} type="search" className="hms-input pl-10" placeholder="Gerät, Marke oder Artikelnummer suchen..." aria-label="Equipment suchen" />
|
||||
</div>
|
||||
<select value={sortBy} onChange={e => { setSortBy(e.target.value as SortOption); setCurrentPage(1); reload(); }} className="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>
|
||||
{categories.length > 1 && (
|
||||
<div className="flex flex-wrap gap-2 mt-4" role="tablist" aria-label="Kategorie-Filter">
|
||||
{categories.map(cat => (
|
||||
<button key={cat} onClick={() => { setActiveCategory(cat); setCurrentPage(1); reload(); }}
|
||||
className={`hms-chip ${activeCategory === cat ? 'active' : ''}`} role="tab" aria-selected={activeCategory === cat}>
|
||||
{cat === 'alle' ? 'Alle Kategorien' : cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!pending && !error && (
|
||||
<div className="text-sm mb-4" style={{ color: 'var(--secondary)' }} aria-live="polite">{total} {total === 1 ? 'Gerät' : 'Geräte'} gefunden</div>
|
||||
)}
|
||||
{pending && <LoadingSkeleton count={6} />}
|
||||
{error && <ErrorState title="Katalog nicht erreichbar" message="Der Equipment-Katalog ist aktuell nicht verfügbar." onRetry={reload} />}
|
||||
{!pending && !error && equipment.length === 0 && (
|
||||
<EmptyState icon="🔍" title="Keine Geräte gefunden" message="Für Ihre Suchanfrage wurden keine Geräte gefunden." actionLabel="Filter zurücksetzen" onAction={() => { setSearchQuery(''); setActiveCategory('alle'); setCurrentPage(1); reload(); }} />
|
||||
)}
|
||||
{!pending && !error && equipment.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{equipment.map(item => (
|
||||
<EquipmentCard key={item.id} item={mapItem(item)} onClick={navigateToDetail} onAddToCart={addToCart} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-8">
|
||||
<button disabled={currentPage <= 1} onClick={() => { setCurrentPage(c => c - 1); reload(); }} className="hms-btn hms-btn-secondary px-4 py-2 disabled:opacity-40" aria-label="Vorherige Seite">←</button>
|
||||
<span className="text-sm" style={{ color: 'var(--secondary)' }}>Seite {currentPage} von {totalPages}</span>
|
||||
<button disabled={currentPage >= totalPages} onClick={() => { setCurrentPage(c => c + 1); reload(); }} className="hms-btn hms-btn-secondary px-4 py-2 disabled:opacity-40" aria-label="Nächste Seite">→</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
|
||||
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 categories = ['alle', 'Open-Air', 'Indoor', 'Rigging', 'Event'];
|
||||
|
||||
export default function Referenzen() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [filter, setFilter] = useState('alle');
|
||||
|
||||
const filteredImages = useMemo(() => {
|
||||
if (filter === 'alle') return allImages;
|
||||
return allImages.filter(i => i.category === filter);
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setLoading(false), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
function retry() {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setTimeout(() => setLoading(false), 800);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold mb-2" style={{ color: 'var(--text)' }}>Referenzen</h1>
|
||||
<p className="mb-8" style={{ color: 'var(--secondary)' }}>Ausgewählte Projekte aus unserer Veranstaltungstechnik-Praxis.</p>
|
||||
<div className="flex flex-wrap gap-2 mb-8" role="tablist" aria-label="Referenz-Filter">
|
||||
{categories.map(cat => (
|
||||
<button key={cat} onClick={() => setFilter(cat)} className={`hms-chip ${filter === cat ? 'active' : ''}`} role="tab" aria-selected={filter === cat}>{cat === 'alle' ? 'Alle' : cat}</button>
|
||||
))}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="hms-gallery">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="hms-card overflow-hidden">
|
||||
<div className="hms-skeleton aspect-[4/3]" style={{ borderRadius: 0 }}></div>
|
||||
<div className="p-4 space-y-2"><div className="hms-skeleton h-4 w-3/4"></div><div className="hms-skeleton h-3 w-1/3"></div></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{error && <ErrorState title="Referenzen konnten nicht geladen werden" message="Bitte versuchen Sie es in Kürze erneut." onRetry={retry} />}
|
||||
{!loading && !error && (
|
||||
<div className="hms-gallery">
|
||||
{filteredImages.map(img => (
|
||||
<article key={img.id} className="hms-card overflow-hidden group cursor-pointer">
|
||||
<div className="aspect-[4/3] relative overflow-hidden">
|
||||
<img src={img.img} alt={img.title} loading="lazy" className="absolute inset-0 w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<span className="hms-badge hms-badge-primary absolute top-3 left-3">{img.category}</span>
|
||||
</div>
|
||||
<div className="p-4"><h3 className="font-semibold text-sm" style={{ color: 'var(--text)' }}>{img.title}</h3><p className="text-xs mt-1" style={{ color: 'var(--secondary)' }}>{img.date}</p></div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && filteredImages.length === 0 && (
|
||||
<EmptyState icon="📭" title="Keine Referenzen" message="Für diese Kategorie liegen aktuell keine Referenzen vor." actionLabel="Alle anzeigen" onAction={() => setFilter('alle')} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCart } from '../store/CartContext';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
|
||||
export default function Warenkorb() {
|
||||
const navigate = useNavigate();
|
||||
const { items: cartItems, totalCount, removeItem, incrementQuantity, decrementQuantity, clearCart } = useCart();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', email: '', phone: '', event_date: '', event_location: '', message: '' });
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
function navigateTo(route: string) {
|
||||
navigate(route);
|
||||
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';
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
}
|
||||
|
||||
function submitRequest(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
setSubmitting(true);
|
||||
setTimeout(() => {
|
||||
setSubmitting(false);
|
||||
setSubmitted(true);
|
||||
clearCart();
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold mb-2" style={{ color: 'var(--text)' }}>Mietanfrage</h1>
|
||||
<p className="mb-8" style={{ color: 'var(--secondary)' }}>Überprüfen Sie Ihre Geräteauswahl und senden Sie die Anfrage ab.</p>
|
||||
{submitted ? (
|
||||
<div className="hms-card p-8 text-center" role="status">
|
||||
<div className="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 className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"/></svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2" style={{ color: 'var(--text)' }}>Mietanfrage übermittelt</h2>
|
||||
<p className="mb-6" style={{ color: 'var(--secondary)' }}>Vielen Dank! Wir melden uns innerhalb von 24 Stunden mit einem unverbindlichen Angebot.</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<button onClick={() => navigateTo('/mietkatalog')} className="hms-btn hms-btn-secondary">Weiter stöbern</button>
|
||||
<button onClick={() => navigateTo('/')} className="hms-btn hms-btn-primary">Zur Startseite</button>
|
||||
</div>
|
||||
</div>
|
||||
) : cartItems.length === 0 ? (
|
||||
<EmptyState icon="🛒" title="Warenkorb ist leer" message="Fügen Sie Geräte aus dem Mietkatalog hinzu, um eine Mietanfrage zu stellen." actionLabel="Zum Mietkatalog" onAction={() => navigateTo('/mietkatalog')} />
|
||||
) : (
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{cartItems.map((item, index) => (
|
||||
<div key={index} className="hms-card p-4 sm:p-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="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 className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm sm:text-base" style={{ color: 'var(--text)' }}>{item.name}</h3>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--secondary)' }}>{item.equipment_id}</div>
|
||||
</div>
|
||||
<button onClick={() => removeItem(item.equipment_id)} className="p-1" style={{ color: 'var(--secondary)' }} aria-label={item.name + ' entfernen'}>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="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 className="flex flex-wrap items-center gap-3 mt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => decrementQuantity(item.equipment_id)} className="w-7 h-7 rounded border flex items-center justify-center" style={{ borderColor: 'var(--border-strong)', color: 'var(--text-muted)' }} aria-label="Anzahl verringern">−</button>
|
||||
<span className="w-8 text-center text-sm font-medium" style={{ color: 'var(--text)' }}>{item.quantity}</span>
|
||||
<button onClick={() => incrementQuantity(item.equipment_id)} className="w-7 h-7 rounded border flex items-center justify-center" style={{ borderColor: 'var(--border-strong)', color: 'var(--text-muted)' }} aria-label="Anzahl erhöhen">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={clearCart} className="text-sm hover:text-[var(--color-error)]" style={{ color: 'var(--secondary)' }}>Warenkorb leeren</button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="hms-card p-6 sticky top-20">
|
||||
<h2 className="text-lg font-semibold mb-4" style={{ color: 'var(--text)' }}>Anfrage senden</h2>
|
||||
<div className="text-sm mb-4 pb-4 border-b" style={{ borderColor: 'var(--border)', color: 'var(--secondary)' }}>
|
||||
<div className="flex justify-between mb-1"><span>Geräte gesamt:</span><span className="font-medium" style={{ color: 'var(--text)' }}>{totalCount}</span></div>
|
||||
<div className="flex justify-between"><span>Positionen:</span><span className="font-medium" style={{ color: 'var(--text)' }}>{cartItems.length}</span></div>
|
||||
</div>
|
||||
<form onSubmit={submitRequest} noValidate>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="req-name" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Name <span style={{ color: 'var(--color-error)' }}>*</span></label>
|
||||
<input id="req-name" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} type="text" className={`hms-input text-sm ${errors.name ? 'hms-input-error' : ''}`} placeholder="Ihr Name" />
|
||||
{errors.name && <p className="text-xs mt-1" style={{ color: 'var(--color-error)' }}>{errors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="req-email" className="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" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} type="email" className={`hms-input text-sm ${errors.email ? 'hms-input-error' : ''}`} placeholder="ihre@email.de" />
|
||||
{errors.email && <p className="text-xs mt-1" style={{ color: 'var(--color-error)' }}>{errors.email}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="req-phone" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Telefon</label>
|
||||
<input id="req-phone" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} type="tel" className="hms-input text-sm" placeholder="+49 ..." />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="req-date" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Veranstaltungsdatum <span style={{ color: 'var(--color-error)' }}>*</span></label>
|
||||
<input id="req-date" value={form.event_date} onChange={e => setForm(f => ({ ...f, event_date: e.target.value }))} type="date" className={`hms-input text-sm ${errors.event_date ? 'hms-input-error' : ''}`} />
|
||||
{errors.event_date && <p className="text-xs mt-1" style={{ color: 'var(--color-error)' }}>{errors.event_date}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="req-location" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Veranstaltungsort <span style={{ color: 'var(--color-error)' }}>*</span></label>
|
||||
<input id="req-location" value={form.event_location} onChange={e => setForm(f => ({ ...f, event_location: e.target.value }))} type="text" className={`hms-input text-sm ${errors.event_location ? 'hms-input-error' : ''}`} placeholder="Ort / Location" />
|
||||
{errors.event_location && <p className="text-xs mt-1" style={{ color: 'var(--color-error)' }}>{errors.event_location}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="req-message" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Anmerkung</label>
|
||||
<textarea id="req-message" value={form.message} onChange={e => setForm(f => ({ ...f, message: e.target.value }))} rows={3} className="hms-input text-sm" placeholder="Zusätzliche Informationen..." />
|
||||
</div>
|
||||
<button type="submit" disabled={submitting} className="hms-btn hms-btn-primary w-full py-3">
|
||||
{submitting ? <span className="hms-spinner" style={{ width: 18, height: 18, borderWidth: 2 }} /> : <span>Anfrage absenden</span>}
|
||||
</button>
|
||||
<p className="text-xs text-center" style={{ color: 'var(--secondary)' }}>Unverbindlich – Angebot innerhalb 24 Std.</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import type { CartItem, EquipmentItem } from '../types';
|
||||
|
||||
const STORAGE_KEY = 'hms-cart';
|
||||
|
||||
interface CartContextType {
|
||||
items: CartItem[];
|
||||
totalCount: number;
|
||||
isEmpty: boolean;
|
||||
hasItems: boolean;
|
||||
apiItems: { equipment_id: number; quantity: number }[];
|
||||
addItem: (equipment: EquipmentItem) => void;
|
||||
addItemByFields: (item: { equipment_id: number; name: string; rental_price: number | null; image_url: string | null }) => void;
|
||||
removeItem: (equipmentId: number) => void;
|
||||
updateQuantity: (equipmentId: number, quantity: number) => void;
|
||||
incrementQuantity: (equipmentId: number) => void;
|
||||
decrementQuantity: (equipmentId: number) => void;
|
||||
clearCart: () => void;
|
||||
}
|
||||
|
||||
const CartContext = createContext<CartContextType | null>(null);
|
||||
|
||||
export function CartProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<CartItem[]>([]);
|
||||
|
||||
// Hydrate from localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed && Array.isArray(parsed.items)) {
|
||||
setItems(parsed.items);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to hydrate cart from localStorage:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Persist to localStorage on changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ items }));
|
||||
} catch (e) {
|
||||
console.warn('Failed to persist cart to localStorage:', e);
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
const addItem = useCallback((equipment: EquipmentItem) => {
|
||||
setItems(prev => {
|
||||
const existing = prev.find(i => i.equipment_id === equipment.id);
|
||||
if (existing) {
|
||||
return prev.map(i => i.equipment_id === equipment.id ? { ...i, quantity: i.quantity + 1 } : i);
|
||||
}
|
||||
return [...prev, { equipment_id: equipment.id, name: equipment.name, quantity: 1, rental_price: equipment.rental_price, image_url: equipment.image_url }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addItemByFields = useCallback((item: { equipment_id: number; name: string; rental_price: number | null; image_url: string | null }) => {
|
||||
setItems(prev => {
|
||||
const existing = prev.find(i => i.equipment_id === item.equipment_id);
|
||||
if (existing) {
|
||||
return prev.map(i => i.equipment_id === item.equipment_id ? { ...i, quantity: i.quantity + 1 } : i);
|
||||
}
|
||||
return [...prev, { equipment_id: item.equipment_id, name: item.name, quantity: 1, rental_price: item.rental_price, image_url: item.image_url }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeItem = useCallback((equipmentId: number) => {
|
||||
setItems(prev => prev.filter(i => i.equipment_id !== equipmentId));
|
||||
}, []);
|
||||
|
||||
const updateQuantity = useCallback((equipmentId: number, quantity: number) => {
|
||||
if (quantity <= 0) {
|
||||
setItems(prev => prev.filter(i => i.equipment_id !== equipmentId));
|
||||
return;
|
||||
}
|
||||
setItems(prev => prev.map(i => i.equipment_id === equipmentId ? { ...i, quantity } : i));
|
||||
}, []);
|
||||
|
||||
const incrementQuantity = useCallback((equipmentId: number) => {
|
||||
setItems(prev => prev.map(i => i.equipment_id === equipmentId ? { ...i, quantity: i.quantity + 1 } : i));
|
||||
}, []);
|
||||
|
||||
const decrementQuantity = useCallback((equipmentId: number) => {
|
||||
setItems(prev => {
|
||||
const item = prev.find(i => i.equipment_id === equipmentId);
|
||||
if (!item) return prev;
|
||||
if (item.quantity <= 1) {
|
||||
return prev.filter(i => i.equipment_id !== equipmentId);
|
||||
}
|
||||
return prev.map(i => i.equipment_id === equipmentId ? { ...i, quantity: i.quantity - 1 } : i);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearCart = useCallback(() => setItems([]), []);
|
||||
|
||||
const totalCount = items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const isEmpty = items.length === 0;
|
||||
const hasItems = items.length > 0;
|
||||
const apiItems = items.map(item => ({ equipment_id: item.equipment_id, quantity: item.quantity }));
|
||||
|
||||
return (
|
||||
<CartContext.Provider value={{ items, totalCount, isEmpty, hasItems, apiItems, addItem, addItemByFields, removeItem, updateQuantity, incrementQuantity, decrementQuantity, clearCart }}>
|
||||
{children}
|
||||
</CartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCart() {
|
||||
const ctx = useContext(CartContext);
|
||||
if (!ctx) throw new Error('useCart must be used within CartProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export interface EquipmentItem {
|
||||
id: number;
|
||||
rentman_id: string;
|
||||
name: string;
|
||||
number: string | null;
|
||||
category: string | null;
|
||||
description: string | null;
|
||||
image_url: string | null;
|
||||
rental_price: number | null;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface EquipmentDetail {
|
||||
id: number;
|
||||
rentman_id: string;
|
||||
name: string;
|
||||
number: string | null;
|
||||
category: string | null;
|
||||
description: string | null;
|
||||
specifications: Record<string, unknown> | null;
|
||||
image_url: string | null;
|
||||
images: string[] | null;
|
||||
rental_price: number | null;
|
||||
brand: string | null;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedEquipment {
|
||||
items: EquipmentItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export type SortOption = "name_asc" | "name_desc";
|
||||
|
||||
export interface CartItem {
|
||||
equipment_id: number;
|
||||
name: string;
|
||||
quantity: number;
|
||||
rental_price: number | null;
|
||||
image_url: string | null;
|
||||
}
|
||||
|
||||
export interface RentalRequestPayload {
|
||||
event_name: string;
|
||||
date_start: string;
|
||||
date_end: string;
|
||||
location: string;
|
||||
person_count: number;
|
||||
contact_name: string;
|
||||
contact_company: string;
|
||||
contact_email: string;
|
||||
contact_phone: string;
|
||||
contact_street: string;
|
||||
contact_postalcode: string;
|
||||
contact_city: string;
|
||||
message: string;
|
||||
items: { equipment_id: number; quantity: number }[];
|
||||
}
|
||||
|
||||
export interface RentalRequestResponse {
|
||||
reference_number: string;
|
||||
status: string;
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { defineStore } from "pinia";
|
||||
import type { EquipmentItem } from "~/composables/useEquipment";
|
||||
|
||||
/**
|
||||
* Cart item structure stored in the Pinia cart store.
|
||||
*/export interface CartItem {
|
||||
equipment_id: number;
|
||||
name: string;
|
||||
quantity: number;
|
||||
rental_price: number | null;
|
||||
image_url: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form payload for rental request submission.
|
||||
*/export interface RentalRequestPayload {
|
||||
event_name: string;
|
||||
date_start: string;
|
||||
date_end: string;
|
||||
location: string;
|
||||
person_count: number;
|
||||
contact_name: string;
|
||||
contact_company: string;
|
||||
contact_email: string;
|
||||
contact_phone: string;
|
||||
contact_street: string;
|
||||
contact_postalcode: string;
|
||||
contact_city: string;
|
||||
message: string;
|
||||
items: { equipment_id: number; quantity: number }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from POST /api/rental-requests.
|
||||
*/export interface RentalRequestResponse {
|
||||
reference_number: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pinia store for the equipment rental cart.
|
||||
* Persists to localStorage via plugins/pinia-persist.client.ts.
|
||||
*/export const useCartStore = defineStore("cart", {
|
||||
state: (): { items: CartItem[] } => ({
|
||||
items: [] as CartItem[],
|
||||
}),
|
||||
|
||||
getters: {
|
||||
/**
|
||||
* Total count of all items (sum of quantities).
|
||||
*/ totalCount(state): number {
|
||||
return state.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether the cart is empty.
|
||||
*/ isEmpty(state): boolean {
|
||||
return state.items.length === 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether the cart has items.
|
||||
*/ hasItems(state): boolean {
|
||||
return state.items.length > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Items as formatted payload for rental request API.
|
||||
*/ apiItems(state): { equipment_id: number; quantity: number }[] {
|
||||
return state.items.map((item) => ({
|
||||
equipment_id: item.equipment_id,
|
||||
quantity: item.quantity,
|
||||
}));
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Add an equipment item to the cart. If it already exists, increment quantity.
|
||||
*/ addItem(equipment: EquipmentItem): void {
|
||||
const existing = this.items.find(
|
||||
(item) => item.equipment_id === equipment.id,
|
||||
);
|
||||
if (existing) {
|
||||
existing.quantity += 1;
|
||||
} else {
|
||||
this.items.push({
|
||||
equipment_id: equipment.id,
|
||||
name: equipment.name,
|
||||
quantity: 1,
|
||||
rental_price: equipment.rental_price,
|
||||
image_url: equipment.image_url,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add an item by explicit fields (used from detail page where full EquipmentItem may not be available).
|
||||
*/ addItemByFields(item: {
|
||||
equipment_id: number;
|
||||
name: string;
|
||||
rental_price: number | null;
|
||||
image_url: string | null;
|
||||
}): void {
|
||||
const existing = this.items.find(
|
||||
(i) => i.equipment_id === item.equipment_id,
|
||||
);
|
||||
if (existing) {
|
||||
existing.quantity += 1;
|
||||
} else {
|
||||
this.items.push({
|
||||
equipment_id: item.equipment_id,
|
||||
name: item.name,
|
||||
quantity: 1,
|
||||
rental_price: item.rental_price,
|
||||
image_url: item.image_url,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove an item from the cart by equipment_id.
|
||||
*/ removeItem(equipmentId: number): void {
|
||||
const idx = this.items.findIndex(
|
||||
(item) => item.equipment_id === equipmentId,
|
||||
);
|
||||
if (idx !== -1) {
|
||||
this.items.splice(idx, 1);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the quantity of a cart item. If quantity <= 0, remove the item.
|
||||
*/ updateQuantity(equipmentId: number, quantity: number): void {
|
||||
if (quantity <= 0) {
|
||||
this.removeItem(equipmentId);
|
||||
return;
|
||||
}
|
||||
const item = this.items.find(
|
||||
(i) => i.equipment_id === equipmentId,
|
||||
);
|
||||
if (item) {
|
||||
item.quantity = quantity;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Increment quantity of a cart item.
|
||||
*/ incrementQuantity(equipmentId: number): void {
|
||||
const item = this.items.find(
|
||||
(i) => i.equipment_id === equipmentId,
|
||||
);
|
||||
if (item) {
|
||||
item.quantity += 1;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Decrement quantity of a cart item. If quantity reaches 0, remove it.
|
||||
*/ decrementQuantity(equipmentId: number): void {
|
||||
const item = this.items.find(
|
||||
(i) => i.equipment_id === equipmentId,
|
||||
);
|
||||
if (item) {
|
||||
item.quantity -= 1;
|
||||
if (item.quantity <= 0) {
|
||||
this.removeItem(equipmentId);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all items from the cart.
|
||||
*/ clearCart(): void {
|
||||
this.items = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -2,11 +2,8 @@ import type { Config } from "tailwindcss";
|
||||
|
||||
export default <Config>{
|
||||
content: [
|
||||
"./components/**/*.{vue,js,ts}",
|
||||
"./layouts/**/*.vue",
|
||||
"./pages/**/*.vue",
|
||||
"./app.vue",
|
||||
"./error.vue",
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
# Test Report – 1:1 Prototype Port
|
||||
|
||||
**Date:** 2026-07-10
|
||||
**Task:** 1:1 Prototyp-Portierung zu Nuxt 3
|
||||
**Commit:** fix: 1:1 prototype port – exact hms-* CSS, real logo, all components from app.js
|
||||
|
||||
## Build Verification
|
||||
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
**Result:** ✅ Build complete
|
||||
**Output size:** 2.47 MB (607 kB gzip)
|
||||
**Server:** .output/server/index.mjs generated successfully
|
||||
|
||||
## Unit Tests
|
||||
|
||||
```
|
||||
npx vitest run --reporter verbose
|
||||
```
|
||||
|
||||
**Result:** ✅ 10 test files, 168 tests passed, 0 failed
|
||||
|
||||
### Test Files:
|
||||
1. `tests/unit/CartStore.test.ts` – Cart store interface and actions ✅
|
||||
2. `tests/unit/DesignTokens.test.ts` – CSS tokens, hms-* classes, Tailwind/Nuxt config ✅
|
||||
3. `tests/unit/HomePage.test.ts` – Hero, services, speaker grid, CTA ✅
|
||||
4. `tests/unit/ReferenzenPage.test.ts` – Gallery, filter chips, loading states ✅
|
||||
5. `tests/unit/KontaktPage.test.ts` – Contact form, validation, addresses ✅
|
||||
6. `tests/unit/MietkatalogPage.test.ts` – Search, filter, equipment cards ✅
|
||||
7. `tests/unit/EquipmentDetailPage.test.ts` – Detail view, specs, related items ✅
|
||||
8. `tests/unit/WarenkorbPage.test.ts` – Cart items, request form, validation ✅
|
||||
9. `tests/unit/Layout.test.ts` – Header, footer, error page, logo, robots.txt ✅
|
||||
10. `tests/unit/useEquipment.test.ts` – API composable, equipment composable ✅
|
||||
|
||||
## Smoke Test (Dev Server)
|
||||
|
||||
```
|
||||
npx nuxt dev (port 3004)
|
||||
curl -s http://localhost:3004/
|
||||
```
|
||||
|
||||
**Result:** ✅ All checks passed
|
||||
|
||||
| Check | Expected | Actual |
|
||||
|-------|----------|--------|
|
||||
| `Veranstaltungstechnik` text | ≥1 | 2 ✅ |
|
||||
| `hms-hero` class | ≥1 | 1 ✅ |
|
||||
| `hms-card` class | ≥1 | 1 ✅ |
|
||||
| `hms-btn-primary` class | ≥1 | 1 ✅ |
|
||||
| `hms-header` class | ≥1 | 1 ✅ |
|
||||
| `hms-speaker-grid` class | ≥1 | 1 ✅ |
|
||||
| `hms-logo-svg` class | ≥1 | 1 ✅ |
|
||||
| `EC6925` (accent color) | ≥1 | 1 ✅ |
|
||||
|
||||
## Files Changed
|
||||
|
||||
### CSS
|
||||
- `assets/css/main.css` – Replaced with prototype-style.css content + Tailwind directives
|
||||
|
||||
### Components (9 ported from app.js)
|
||||
- `components/HmsLogo.vue` – SVG logo with #EC6925 border
|
||||
- `components/SpeakerIcon.vue` – 6 speaker type SVG icons
|
||||
- `components/AppHeader.vue` – hms-header, nav, mobile menu
|
||||
- `components/AppFooter.vue` – hms-footer, 4-column grid
|
||||
- `components/ServiceCard.vue` – hms-card with icon circle
|
||||
- `components/EquipmentCard.vue` – hms-eq-card with badge
|
||||
- `components/LoadingSkeleton.vue` – hms-skeleton shimmer
|
||||
- `components/EmptyState.vue` – Empty state with action
|
||||
- `components/ErrorState.vue` – Error state with retry
|
||||
|
||||
### Pages (8 ported from app.js)
|
||||
- `pages/index.vue` – HomePage with hero, speakers, services, CTA
|
||||
- `pages/referenzen.vue` – ReferenzenPage with gallery and filters
|
||||
- `pages/kontakt.vue` – KontaktPage with form and contact info
|
||||
- `pages/mietkatalog.vue` – MietkatalogPage with 18 equipment items
|
||||
- `pages/mietkatalog/[id].vue` – EquipmentDetailPage with specs and related
|
||||
- `pages/warenkorb.vue` – WarenkorbPage with cart and request form
|
||||
- `pages/admin.vue` – AdminPage with login form
|
||||
- `error.vue` – NotFoundPage (404)
|
||||
|
||||
### Layout
|
||||
- `layouts/default.vue` – Updated to match prototype App structure
|
||||
|
||||
### Tests (10 files updated)
|
||||
- All unit tests rewritten to match prototype structure
|
||||
- Removed MietanfragePage.test.ts (page not in prototype)
|
||||
- Tests check for hms-* classes, prototype templates, exact content
|
||||
|
||||
### Removed
|
||||
- `components/CartDrawer.vue` – Not in prototype
|
||||
- `components/Lightbox.vue` – Not in prototype
|
||||
- `pages/mietanfrage.vue` – Not in prototype (functionality in warenkorb)
|
||||
|
||||
### Kept (unchanged)
|
||||
- `composables/useApi.ts`, `composables/useEquipment.ts`, `composables/useCart.ts`
|
||||
- `stores/cart.ts`
|
||||
- `plugins/pinia-persist.client.ts`
|
||||
- `components/LegalContentPage.vue` (used by legal pages)
|
||||
- `pages/impressum.vue`, `pages/datenschutz.vue`, `pages/agb-vermietung.vue`
|
||||
- `nuxt.config.ts`, `tailwind.config.ts`
|
||||
@@ -1,68 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Cart Flow", () => {
|
||||
test("should render warenkorb page with heading", async ({ page }) => {
|
||||
await page.goto("/warenkorb");
|
||||
await expect(page.locator("h1")).toContainText("Warenkorb");
|
||||
});
|
||||
|
||||
test("should show empty state when cart is empty", async ({ page }) => {
|
||||
await page.goto("/warenkorb");
|
||||
await expect(page.getByTestId("warenkorb-empty")).toBeVisible();
|
||||
await expect(page.getByTestId("warenkorb-empty-cta")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should have link to mietkatalog from empty state", async ({ page }) => {
|
||||
await page.goto("/warenkorb");
|
||||
const cta = page.getByTestId("warenkorb-empty-cta");
|
||||
await expect(cta).toHaveAttribute("href", "/mietkatalog");
|
||||
});
|
||||
|
||||
test("should show header cart icon", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("header-cart-button")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should open cart drawer when header cart icon clicked", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("header-cart-button").click();
|
||||
await expect(page.getByTestId("cart-drawer")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show empty state in cart drawer", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("header-cart-button").click();
|
||||
await expect(page.getByTestId("cart-drawer")).toBeVisible();
|
||||
await expect(page.locator("text=Ihr Warenkorb ist leer")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should close cart drawer on backdrop click", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("header-cart-button").click();
|
||||
await expect(page.getByTestId("cart-drawer")).toBeVisible();
|
||||
await page.getByTestId("cart-drawer-backdrop").click();
|
||||
await expect(page.getByTestId("cart-drawer")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("should have checkout link in cart drawer", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("header-cart-button").click();
|
||||
await expect(page.getByTestId("cart-drawer")).toBeVisible();
|
||||
const checkout = page.getByTestId("cart-drawer-checkout");
|
||||
await expect(checkout).toHaveAttribute("href", "/mietanfrage");
|
||||
});
|
||||
|
||||
test("should have view cart link in cart drawer", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("header-cart-button").click();
|
||||
await expect(page.getByTestId("cart-drawer")).toBeVisible();
|
||||
const viewCart = page.getByTestId("cart-drawer-view-cart");
|
||||
await expect(viewCart).toHaveAttribute("href", "/warenkorb");
|
||||
});
|
||||
|
||||
test("should have mobile cart button", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await expect(page.getByTestId("header-cart-button-mobile")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Equipment Detail Page", () => {
|
||||
test("should show error for non-existent ID", async ({ page }) => {
|
||||
await page.goto("/mietkatalog/999999");
|
||||
await expect(page.getByText("nicht gefunden")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should have link back to mietkatalog on error", async ({ page }) => {
|
||||
await page.goto("/mietkatalog/999999");
|
||||
const link = page.getByText("Zurück zum Mietkatalog");
|
||||
await expect(link).toBeVisible();
|
||||
});
|
||||
|
||||
test("should display breadcrumb navigation", async ({ page }) => {
|
||||
await page.goto("/mietkatalog/1");
|
||||
await expect(page.getByText("Home")).toBeVisible();
|
||||
await expect(page.getByText("Mietkatalog")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show equipment name as heading", async ({ page }) => {
|
||||
await page.goto("/mietkatalog/1");
|
||||
const name = page.getByTestId("equipment-name");
|
||||
const errorState = page.getByText("nicht gefunden");
|
||||
const hasContent = await name.isVisible().catch(() => false) || await errorState.isVisible().catch(() => false);
|
||||
expect(hasContent).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should show specs section if equipment found", async ({ page }) => {
|
||||
await page.goto("/mietkatalog/1");
|
||||
const specs = page.getByTestId("equipment-specs");
|
||||
const errorState = page.getByText("nicht gefunden");
|
||||
const hasContent = await specs.isVisible().catch(() => false) || await errorState.isVisible().catch(() => false);
|
||||
expect(hasContent).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should have Mietanfrage button", async ({ page }) => {
|
||||
await page.goto("/mietkatalog/1");
|
||||
const btn = page.getByTestId("add-to-cart-detail");
|
||||
const errorState = page.getByText("nicht gefunden");
|
||||
const hasContent = await btn.isVisible().catch(() => false) || await errorState.isVisible().catch(() => false);
|
||||
expect(hasContent).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should navigate back to mietkatalog via breadcrumb", async ({ page }) => {
|
||||
await page.goto("/mietkatalog/1");
|
||||
const breadcrumbLink = page.locator('a[href="/mietkatalog"]').first();
|
||||
if (await breadcrumbLink.isVisible()) {
|
||||
await breadcrumbLink.click();
|
||||
await page.waitForURL("/mietkatalog");
|
||||
await expect(page).toHaveURL(/\/mietkatalog$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Error Pages", () => {
|
||||
test("404 page should show 'Seite nicht gefunden'", async ({ page }) => {
|
||||
const response = await page.goto("/nicht-existent");
|
||||
expect(response?.status()).toBe(404);
|
||||
await expect(page.locator("body")).toContainText("Seite nicht gefunden");
|
||||
await expect(page.getByRole("link", { name: /Startseite/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Header", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("should display sticky header with logo", async ({ page }) => {
|
||||
const header = page.locator("header");
|
||||
await expect(header).toBeVisible();
|
||||
await expect(header).toHaveClass(/sticky/);
|
||||
});
|
||||
|
||||
test("should have navigation items", async ({ page }) => {
|
||||
await expect(page.getByRole("navigation").filter({ hasText: "Home" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Referenzen" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Mietkatalog" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Kontakt" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("should have clickable phone link", async ({ page }) => {
|
||||
const phoneLink = page.locator('a[href="tel:+491726264796"]');
|
||||
await expect(phoneLink).toBeVisible();
|
||||
});
|
||||
|
||||
test("should have social icons", async ({ page }) => {
|
||||
await expect(page.locator('a[aria-label="Facebook"]')).toBeVisible();
|
||||
await expect(page.locator('a[aria-label="Instagram"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("should open mobile burger menu on click", async ({ page }) => {
|
||||
const burger = page.locator('button[aria-label="Menü öffnen/schließen"]');
|
||||
await expect(burger).toBeVisible();
|
||||
await burger.click();
|
||||
await expect(page.locator("#mobile-nav")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Footer", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("should display footer with address", async ({ page }) => {
|
||||
const footer = page.locator("footer");
|
||||
await expect(footer).toBeVisible();
|
||||
await expect(footer).toContainText("Leipheim");
|
||||
await expect(footer).toContainText("89340");
|
||||
});
|
||||
|
||||
test("should have phone number", async ({ page }) => {
|
||||
await expect(page.locator('a[href="tel:+498221204433"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("should have email link", async ({ page }) => {
|
||||
await expect(page.locator('a[href="mailto:info@hms-licht-ton.de"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("should have legal links", async ({ page }) => {
|
||||
await expect(page.getByRole("link", { name: "Impressum" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "DSGVO" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "AGB Vermietung" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("should NOT contain 'Neu in der Vermietung'", async ({ page }) => {
|
||||
const footer = page.locator("footer");
|
||||
await expect(footer).not.toContainText("Neu in der Vermietung");
|
||||
});
|
||||
|
||||
test("should NOT contain 'AGB Shop'", async ({ page }) => {
|
||||
const footer = page.locator("footer");
|
||||
await expect(footer).not.toContainText("AGB Shop");
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Home Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("should display hero with headline 'Veranstaltungstechnik'", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="hero-headline"]')).toContainText("Veranstaltungstechnik");
|
||||
});
|
||||
|
||||
test("should have CTA button linking to /mietkatalog", async ({ page }) => {
|
||||
const cta = page.locator('[data-testid="hero-cta-mietkatalog"]');
|
||||
await expect(cta).toBeVisible();
|
||||
await expect(cta).toHaveAttribute("to", "/mietkatalog");
|
||||
});
|
||||
|
||||
test("should have CTA button linking to /kontakt", async ({ page }) => {
|
||||
const cta = page.locator('[data-testid="hero-cta-kontakt"]');
|
||||
await expect(cta).toBeVisible();
|
||||
await expect(cta).toHaveAttribute("to", "/kontakt");
|
||||
});
|
||||
|
||||
test("should display all 8 services", async ({ page }) => {
|
||||
const servicesSection = page.locator('[data-testid="services-section"]');
|
||||
await expect(servicesSection).toBeVisible();
|
||||
await expect(servicesSection).toContainText("Vermietung");
|
||||
await expect(servicesSection).toContainText("Verkauf");
|
||||
await expect(servicesSection).toContainText("Personal");
|
||||
await expect(servicesSection).toContainText("Transport");
|
||||
await expect(servicesSection).toContainText("Lagerung");
|
||||
await expect(servicesSection).toContainText("Werkstatt");
|
||||
await expect(servicesSection).toContainText("Installation");
|
||||
await expect(servicesSection).toContainText("Booking");
|
||||
});
|
||||
|
||||
test("should display speaker grid with 6 equipment types", async ({ page }) => {
|
||||
const grid = page.locator('[data-testid="speaker-grid"]');
|
||||
await expect(grid).toBeVisible();
|
||||
await expect(grid).toContainText("Lautsprecher");
|
||||
await expect(grid).toContainText("Mischpulte");
|
||||
await expect(grid).toContainText("Lichtanlagen");
|
||||
await expect(grid).toContainText("Rigging");
|
||||
await expect(grid).toContainText("Traversen");
|
||||
await expect(grid).toContainText("Komplett-Setups");
|
||||
});
|
||||
|
||||
test("should display about section with stats", async ({ page }) => {
|
||||
const about = page.locator('[data-testid="about-section"]');
|
||||
await expect(about).toBeVisible();
|
||||
await expect(about).toContainText("20+");
|
||||
await expect(about).toContainText("1000+");
|
||||
await expect(about).toContainText("500+");
|
||||
});
|
||||
|
||||
test("should have mietkatalog CTA section", async ({ page }) => {
|
||||
const cta = page.locator('[data-testid="mietkatalog-cta"]');
|
||||
await expect(cta).toBeVisible();
|
||||
const link = page.locator('[data-testid="cta-mietkatalog-link"]');
|
||||
await expect(link).toBeVisible();
|
||||
});
|
||||
|
||||
test("should be mobile-responsive at 375px width", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await expect(page.locator('[data-testid="hero-headline"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="services-section"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Kontakt Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/kontakt");
|
||||
});
|
||||
|
||||
test("should display contact page heading", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="kontakt-headline"]')).toContainText("Kontakt");
|
||||
});
|
||||
|
||||
test("should show office address card", async ({ page }) => {
|
||||
const card = page.locator('[data-testid="office-card"]');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toContainText("Grockelhofen 10");
|
||||
await expect(card).toContainText("89340 Leipheim");
|
||||
});
|
||||
|
||||
test("should show warehouse address card", async ({ page }) => {
|
||||
const card = page.locator('[data-testid="warehouse-card"]');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toContainText("Ellzee");
|
||||
});
|
||||
|
||||
test("should show opening hours card", async ({ page }) => {
|
||||
const card = page.locator('[data-testid="hours-card"]');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toContainText("Öffnungszeiten");
|
||||
});
|
||||
|
||||
test("should show 2 contact persons", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="contact-person-markus"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="contact-person-thomas"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("should have form with all required fields", async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="form-name"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="form-email"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="form-phone"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="form-message"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="form-privacy"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="form-submit"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("should block submit when email is empty", async ({ page }) => {
|
||||
await page.locator('[data-testid="form-name"]').fill("Test User");
|
||||
await page.locator('[data-testid="form-message"]').fill("Test message");
|
||||
await page.locator('[data-testid="form-privacy"]').check();
|
||||
await page.locator('[data-testid="form-submit"]').click();
|
||||
await expect(page.locator('[data-testid="error-email"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("should block submit when privacy consent unchecked", async ({ page }) => {
|
||||
await page.locator('[data-testid="form-name"]').fill("Test User");
|
||||
await page.locator('[data-testid="form-email"]').fill("test@example.com");
|
||||
await page.locator('[data-testid="form-message"]').fill("Test message");
|
||||
await page.locator('[data-testid="form-submit"]').click();
|
||||
await expect(page.locator('[data-testid="error-privacy"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("should block submit when name is empty", async ({ page }) => {
|
||||
await page.locator('[data-testid="form-email"]').fill("test@example.com");
|
||||
await page.locator('[data-testid="form-message"]').fill("Test message");
|
||||
await page.locator('[data-testid="form-privacy"]').check();
|
||||
await page.locator('[data-testid="form-submit"]').click();
|
||||
await expect(page.locator('[data-testid="error-name"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("should be mobile-responsive at 375px width", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await expect(page.locator('[data-testid="contact-form"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="office-card"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Impressum Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/impressum");
|
||||
});
|
||||
|
||||
test("should display Impressum heading", async ({ page }) => {
|
||||
await expect(page.locator("h1")).toContainText("Impressum");
|
||||
});
|
||||
|
||||
test("should contain Hammerschmidt u. Mössle GbR", async ({ page }) => {
|
||||
await expect(page.locator("body")).toContainText("Hammerschmidt u. Mössle GbR");
|
||||
});
|
||||
|
||||
test("should contain address Grockelhofen 10, 89340 Leipheim", async ({ page }) => {
|
||||
await expect(page.locator("body")).toContainText("Grockelhofen 10");
|
||||
await expect(page.locator("body")).toContainText("89340 Leipheim");
|
||||
});
|
||||
|
||||
test("should contain phone number", async ({ page }) => {
|
||||
await expect(page.locator("body")).toContainText("204433");
|
||||
});
|
||||
|
||||
test("should contain email info@hms-licht-ton.de", async ({ page }) => {
|
||||
await expect(page.locator('a[href="mailto:info@hms-licht-ton.de"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("should be mobile-responsive at 375px width", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await expect(page.locator("h1")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Datenschutz Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/datenschutz");
|
||||
});
|
||||
|
||||
test("should display Datenschutz heading", async ({ page }) => {
|
||||
await expect(page.locator("h1")).toContainText("Datenschutz");
|
||||
});
|
||||
|
||||
test("should contain DSGVO-relevant sections", async ({ page }) => {
|
||||
const body = page.locator("body");
|
||||
await expect(body).toContainText("Datenerhebung");
|
||||
await expect(body).toContainText("Cookies");
|
||||
await expect(body).toContainText("Kontaktformular");
|
||||
await expect(body).toContainText("Server-Log");
|
||||
await expect(body).toContainText("Drittanbieter");
|
||||
});
|
||||
|
||||
test("should mention responsible party", async ({ page }) => {
|
||||
await expect(page.locator("body")).toContainText("Hammerschmidt u. Mössle GbR");
|
||||
});
|
||||
|
||||
test("should be mobile-responsive at 375px width", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await expect(page.locator("h1")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("AGB Vermietung Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/agb-vermietung");
|
||||
});
|
||||
|
||||
test("should display AGB heading", async ({ page }) => {
|
||||
await expect(page.locator("h1")).toContainText("AGB");
|
||||
});
|
||||
|
||||
test("should contain Geltungsbereich section", async ({ page }) => {
|
||||
await expect(page.locator("body")).toContainText("Geltungsbereich");
|
||||
});
|
||||
|
||||
test("should contain Preise section", async ({ page }) => {
|
||||
await expect(page.locator("body")).toContainText("Preise");
|
||||
});
|
||||
|
||||
test("should contain Zahlungsbedingungen section", async ({ page }) => {
|
||||
await expect(page.locator("body")).toContainText("Zahlungsbedingungen");
|
||||
});
|
||||
|
||||
test("should contain Haftung section", async ({ page }) => {
|
||||
await expect(page.locator("body")).toContainText("Haftung");
|
||||
});
|
||||
|
||||
test("should contain Rückgabe section", async ({ page }) => {
|
||||
await expect(page.locator("body")).toContainText("Rückgabe");
|
||||
});
|
||||
|
||||
test("should be mobile-responsive at 375px width", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await expect(page.locator("h1")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Meta and Robots", () => {
|
||||
test("robots.txt should contain Disallow: /", async ({ request }) => {
|
||||
const response = await request.get("/robots.txt");
|
||||
const text = await response.text();
|
||||
expect(text).toContain("User-agent: *");
|
||||
expect(text).toContain("Disallow: /");
|
||||
});
|
||||
|
||||
test("home page should have noindex meta tag", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const robotsMeta = page.locator('meta[name="robots"]');
|
||||
await expect(robotsMeta).toHaveAttribute("content", /noindex/);
|
||||
await expect(robotsMeta).toHaveAttribute("content", /nofollow/);
|
||||
await expect(robotsMeta).toHaveAttribute("content", /noarchive/);
|
||||
await expect(robotsMeta).toHaveAttribute("content", /nosnippet/);
|
||||
});
|
||||
|
||||
test("home page should contain JSON-LD LocalBusiness", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const jsonld = page.locator('script[type="application/ld+json"]');
|
||||
const content = await jsonld.textContent();
|
||||
expect(content).toContain("LocalBusiness");
|
||||
expect(content).toContain("Hammerschmidt");
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Mietanfrage Page", () => {
|
||||
test("should render mietanfrage page with heading", async ({ page }) => {
|
||||
await page.goto("/mietanfrage");
|
||||
await expect(page.locator("h1")).toContainText("Mietanfrage");
|
||||
});
|
||||
|
||||
test("should show empty cart state when no items in cart", async ({ page }) => {
|
||||
await page.goto("/mietanfrage");
|
||||
await expect(page.getByTestId("mietanfrage-empty-cart")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should have link to mietkatalog from empty cart state", async ({ page }) => {
|
||||
await page.goto("/mietanfrage");
|
||||
const cta = page.getByTestId("mietanfrage-empty-cta");
|
||||
await expect(cta).toBeVisible();
|
||||
await expect(cta).toHaveAttribute("href", "/mietkatalog");
|
||||
});
|
||||
|
||||
test("should have breadcrumb with warenkorb link", async ({ page }) => {
|
||||
await page.goto("/mietanfrage");
|
||||
const breadcrumb = page.locator('nav[aria-label="Breadcrumb"]');
|
||||
await expect(breadcrumb).toBeVisible();
|
||||
await expect(breadcrumb.locator('a[href="/warenkorb"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Mietkatalog Page", () => {
|
||||
test("should render SSR HTML with Mietkatalog heading", async ({ page }) => {
|
||||
await page.goto("/mietkatalog");
|
||||
await expect(page.locator("h1")).toContainText("Mietkatalog");
|
||||
});
|
||||
|
||||
test("should have search input visible", async ({ page }) => {
|
||||
await page.goto("/mietkatalog");
|
||||
const searchInput = page.getByTestId("equipment-search");
|
||||
await expect(searchInput).toBeVisible();
|
||||
});
|
||||
|
||||
test("should have sort dropdown with name options", async ({ page }) => {
|
||||
await page.goto("/mietkatalog");
|
||||
const sortSelect = page.getByTestId("sort-select");
|
||||
await expect(sortSelect).toBeVisible();
|
||||
await expect(sortSelect.locator("option")).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("should have category filter chips", async ({ page }) => {
|
||||
await page.goto("/mietkatalog");
|
||||
const chips = page.getByTestId("category-chips");
|
||||
await expect(chips).toBeVisible();
|
||||
});
|
||||
|
||||
test("should have reset filters button", async ({ page }) => {
|
||||
await page.goto("/mietkatalog");
|
||||
const resetBtn = page.getByTestId("reset-filters");
|
||||
await expect(resetBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test("should filter equipment by search input", async ({ page }) => {
|
||||
await page.goto("/mietkatalog");
|
||||
const searchInput = page.getByTestId("equipment-search");
|
||||
await searchInput.fill("Lautsprecher");
|
||||
await page.waitForTimeout(500);
|
||||
const grid = page.getByTestId("equipment-grid");
|
||||
await expect(grid).toBeVisible();
|
||||
});
|
||||
|
||||
test("should reset all filters when reset button clicked", async ({ page }) => {
|
||||
await page.goto("/mietkatalog");
|
||||
const searchInput = page.getByTestId("equipment-search");
|
||||
await searchInput.fill("Test");
|
||||
const resetBtn = page.getByTestId("reset-filters");
|
||||
await resetBtn.click();
|
||||
await expect(searchInput).toHaveValue("");
|
||||
});
|
||||
|
||||
test("should show result count", async ({ page }) => {
|
||||
await page.goto("/mietkatalog");
|
||||
const resultCount = page.getByTestId("result-count");
|
||||
await expect(resultCount).toBeVisible();
|
||||
});
|
||||
|
||||
test("should display equipment cards or empty state", async ({ page }) => {
|
||||
await page.goto("/mietkatalog");
|
||||
const grid = page.getByTestId("equipment-grid");
|
||||
const emptyState = page.getByText("Keine Artikel gefunden");
|
||||
const isVisible = await grid.isVisible().catch(() => false) || await emptyState.isVisible().catch(() => false);
|
||||
expect(isVisible).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should navigate to detail page on card click", async ({ page }) => {
|
||||
await page.goto("/mietkatalog");
|
||||
const card = page.getByTestId("equipment-card").first();
|
||||
if (await card.isVisible()) {
|
||||
const link = card.locator("a[href*='/mietkatalog/']").first();
|
||||
await link.click();
|
||||
await page.waitForURL(/\/mietkatalog\/\d+/);
|
||||
await expect(page).toHaveURL(/\/mietkatalog\/\d+/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Referenzen Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/referenzen");
|
||||
});
|
||||
|
||||
test("should display page heading", async ({ page }) => {
|
||||
await expect(page).toHaveTitle(/Referenzen/);
|
||||
});
|
||||
|
||||
test("should show 9 gallery images in grid", async ({ page }) => {
|
||||
const grid = page.locator('[data-testid="gallery-grid"]');
|
||||
await expect(grid).toBeVisible();
|
||||
const items = grid.locator('[data-testid^="gallery-item-"]');
|
||||
await expect(items).toHaveCount(9);
|
||||
});
|
||||
|
||||
test("should have 4 category filter chips", async ({ page }) => {
|
||||
const chips = page.locator('[data-testid="filter-chips"]');
|
||||
await expect(chips).toBeVisible();
|
||||
await expect(chips).toContainText("Alle");
|
||||
await expect(chips).toContainText("Open-Air");
|
||||
await expect(chips).toContainText("Indoor");
|
||||
await expect(chips).toContainText("Rigging");
|
||||
});
|
||||
|
||||
test("should filter images by category", async ({ page }) => {
|
||||
// Click Open-Air filter
|
||||
await page.locator('[data-testid="filter-open-air"]').click();
|
||||
const grid = page.locator('[data-testid="gallery-grid"]');
|
||||
const items = grid.locator('[data-testid^="gallery-item-"]');
|
||||
const count = await items.count();
|
||||
expect(count).toBeLessThan(9);
|
||||
expect(count).toBeGreaterThan(0);
|
||||
|
||||
// Click Alle to reset
|
||||
await page.locator('[data-testid="filter-alle"]').click();
|
||||
await expect(items).toHaveCount(9);
|
||||
});
|
||||
|
||||
test("should open lightbox on image click", async ({ page }) => {
|
||||
const firstItem = page.locator('[data-testid="gallery-item-1"]');
|
||||
await firstItem.click();
|
||||
const lightbox = page.locator('[data-testid="lightbox"]');
|
||||
await expect(lightbox).toBeVisible();
|
||||
});
|
||||
|
||||
test("should close lightbox with close button", async ({ page }) => {
|
||||
await page.locator('[data-testid="gallery-item-1"]').click();
|
||||
const lightbox = page.locator('[data-testid="lightbox"]');
|
||||
await expect(lightbox).toBeVisible();
|
||||
await page.locator('[data-testid="lightbox-close"]').click();
|
||||
await expect(lightbox).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("should navigate to next image in lightbox", async ({ page }) => {
|
||||
await page.locator('[data-testid="gallery-item-1"]').click();
|
||||
const lightbox = page.locator('[data-testid="lightbox"]');
|
||||
await expect(lightbox).toBeVisible();
|
||||
const firstCaption = await lightbox.locator("p").textContent();
|
||||
await page.locator('[data-testid="lightbox-next"]').click();
|
||||
const secondCaption = await lightbox.locator("p").textContent();
|
||||
expect(firstCaption).not.toBe(secondCaption);
|
||||
});
|
||||
|
||||
test("should navigate to prev image in lightbox", async ({ page }) => {
|
||||
await page.locator('[data-testid="gallery-item-1"]').click();
|
||||
const lightbox = page.locator('[data-testid="lightbox"]');
|
||||
await expect(lightbox).toBeVisible();
|
||||
await page.locator('[data-testid="lightbox-prev"]').click();
|
||||
await expect(lightbox).toBeVisible();
|
||||
});
|
||||
|
||||
test("should be mobile-responsive at 375px width", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await expect(page.locator('[data-testid="gallery-grid"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="filter-chips"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
describe("Cart Store", () => {
|
||||
const storePath = resolve(__dirname, "../../stores/cart.ts");
|
||||
const content = readFileSync(storePath, "utf-8");
|
||||
|
||||
it("should define useCartStore with defineStore", () => {
|
||||
expect(content).toContain("defineStore");
|
||||
expect(content).toContain("useCartStore");
|
||||
});
|
||||
|
||||
it("should have CartItem interface with required fields", () => {
|
||||
expect(content).toContain("equipment_id");
|
||||
expect(content).toContain("name");
|
||||
expect(content).toContain("quantity");
|
||||
expect(content).toContain("rental_price");
|
||||
expect(content).toContain("image_url");
|
||||
});
|
||||
|
||||
it("should have totalCount getter", () => {
|
||||
expect(content).toContain("totalCount");
|
||||
expect(content).toContain("reduce");
|
||||
});
|
||||
|
||||
it("should have isEmpty getter", () => {
|
||||
expect(content).toContain("isEmpty");
|
||||
});
|
||||
|
||||
it("should have hasItems getter", () => {
|
||||
expect(content).toContain("hasItems");
|
||||
});
|
||||
|
||||
it("should have apiItems getter for rental request payload", () => {
|
||||
expect(content).toContain("apiItems");
|
||||
expect(content).toContain("equipment_id");
|
||||
expect(content).toContain("quantity");
|
||||
});
|
||||
|
||||
it("should have addItem action", () => {
|
||||
expect(content).toContain("addItem");
|
||||
expect(content).toContain("existing");
|
||||
expect(content).toContain("quantity += 1");
|
||||
});
|
||||
|
||||
it("should have addItemByFields action", () => {
|
||||
expect(content).toContain("addItemByFields");
|
||||
});
|
||||
|
||||
it("should have removeItem action", () => {
|
||||
expect(content).toContain("removeItem");
|
||||
expect(content).toContain("splice");
|
||||
});
|
||||
|
||||
it("should have updateQuantity action", () => {
|
||||
expect(content).toContain("updateQuantity");
|
||||
expect(content).toContain("quantity <= 0");
|
||||
});
|
||||
|
||||
it("should have incrementQuantity action", () => {
|
||||
expect(content).toContain("incrementQuantity");
|
||||
});
|
||||
|
||||
it("should have decrementQuantity action", () => {
|
||||
expect(content).toContain("decrementQuantity");
|
||||
});
|
||||
|
||||
it("should have clearCart action", () => {
|
||||
expect(content).toContain("clearCart");
|
||||
expect(content).toContain("items = []");
|
||||
});
|
||||
|
||||
it("should have RentalRequestPayload interface", () => {
|
||||
expect(content).toContain("RentalRequestPayload");
|
||||
expect(content).toContain("event_name");
|
||||
expect(content).toContain("date_start");
|
||||
expect(content).toContain("date_end");
|
||||
expect(content).toContain("location");
|
||||
expect(content).toContain("person_count");
|
||||
expect(content).toContain("contact_name");
|
||||
expect(content).toContain("contact_email");
|
||||
expect(content).toContain("contact_phone");
|
||||
expect(content).toContain("contact_street");
|
||||
expect(content).toContain("contact_postalcode");
|
||||
expect(content).toContain("contact_city");
|
||||
expect(content).toContain("message");
|
||||
expect(content).toContain("items");
|
||||
});
|
||||
|
||||
it("should have RentalRequestResponse interface", () => {
|
||||
expect(content).toContain("RentalRequestResponse");
|
||||
expect(content).toContain("reference_number");
|
||||
expect(content).toContain("status");
|
||||
});
|
||||
|
||||
it("should use TypeScript", () => {
|
||||
expect(content).toContain("import type");
|
||||
expect(content).toContain("export interface");
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
describe("Design Tokens (prototype-style.css)", () => {
|
||||
const cssPath = resolve(__dirname, "../../assets/css/main.css");
|
||||
const cssContent = readFileSync(cssPath, "utf-8");
|
||||
|
||||
it("should define --bg variable in :root", () => {
|
||||
expect(cssContent).toContain("--bg: #131313");
|
||||
});
|
||||
|
||||
it("should define --panel variable in :root", () => {
|
||||
expect(cssContent).toContain("--panel: #1a1a1a");
|
||||
});
|
||||
|
||||
it("should define --color-accent variable in :root", () => {
|
||||
expect(cssContent).toContain("--color-accent: #EC6925");
|
||||
});
|
||||
|
||||
it("should define --surface variable in :root", () => {
|
||||
expect(cssContent).toContain("--surface: #212121");
|
||||
});
|
||||
|
||||
it("should define --text variable in :root", () => {
|
||||
expect(cssContent).toContain("--text: #ffffff");
|
||||
});
|
||||
|
||||
it("should define --text-muted variable in :root", () => {
|
||||
expect(cssContent).toContain("--text-muted: #d4d4d4");
|
||||
});
|
||||
|
||||
it("should define radius tokens", () => {
|
||||
expect(cssContent).toContain("--radius-sm: 2px");
|
||||
expect(cssContent).toContain("--radius-md: 3px");
|
||||
expect(cssContent).toContain("--radius-lg: 4px");
|
||||
});
|
||||
|
||||
it("should define transition tokens", () => {
|
||||
expect(cssContent).toContain("--transition-fast: 150ms ease");
|
||||
});
|
||||
|
||||
it("should define hms-* CSS classes", () => {
|
||||
expect(cssContent).toContain(".hms-header");
|
||||
expect(cssContent).toContain(".hms-hero");
|
||||
expect(cssContent).toContain(".hms-card");
|
||||
expect(cssContent).toContain(".hms-btn");
|
||||
expect(cssContent).toContain(".hms-btn-primary");
|
||||
expect(cssContent).toContain(".hms-btn-secondary");
|
||||
expect(cssContent).toContain(".hms-nav-btn");
|
||||
expect(cssContent).toContain(".hms-badge");
|
||||
expect(cssContent).toContain(".hms-input");
|
||||
expect(cssContent).toContain(".hms-skeleton");
|
||||
expect(cssContent).toContain(".hms-spinner");
|
||||
expect(cssContent).toContain(".hms-speaker-grid");
|
||||
expect(cssContent).toContain(".hms-footer");
|
||||
expect(cssContent).toContain(".hms-gallery");
|
||||
expect(cssContent).toContain(".hms-eq-card");
|
||||
expect(cssContent).toContain(".hms-chip");
|
||||
});
|
||||
|
||||
it("should include Tailwind directives", () => {
|
||||
expect(cssContent).toContain("@tailwind base");
|
||||
expect(cssContent).toContain("@tailwind components");
|
||||
expect(cssContent).toContain("@tailwind utilities");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tailwind Config", () => {
|
||||
const configPath = resolve(__dirname, "../../tailwind.config.ts");
|
||||
const configContent = readFileSync(configPath, "utf-8");
|
||||
|
||||
it("should extend colors with accent", () => {
|
||||
expect(configContent).toContain("accent");
|
||||
expect(configContent).toContain("#EC6925");
|
||||
});
|
||||
|
||||
it("should configure Inter font family", () => {
|
||||
expect(configContent).toContain("Inter");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nuxt Config", () => {
|
||||
const configPath = resolve(__dirname, "../../nuxt.config.ts");
|
||||
const configContent = readFileSync(configPath, "utf-8");
|
||||
|
||||
it("should include noindex robots meta tag", () => {
|
||||
expect(configContent).toContain("noindex");
|
||||
expect(configContent).toContain("nofollow");
|
||||
});
|
||||
|
||||
it("should include JSON-LD LocalBusiness script", () => {
|
||||
expect(configContent).toContain("LocalBusiness");
|
||||
expect(configContent).toContain("schema.org");
|
||||
});
|
||||
|
||||
it("should include routeRules for SSR/CSR", () => {
|
||||
expect(configContent).toContain("routeRules");
|
||||
expect(configContent).toContain("/mietkatalog");
|
||||
expect(configContent).toContain("/admin");
|
||||
});
|
||||
|
||||
it("should include Tailwind CSS module", () => {
|
||||
expect(configContent).toContain("@nuxtjs/tailwindcss");
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
describe("Equipment Detail Page (prototype port)", () => {
|
||||
const pagePath = resolve(__dirname, "../../pages/mietkatalog/[id].vue");
|
||||
const content = readFileSync(pagePath, "utf-8");
|
||||
|
||||
it("should have breadcrumb navigation back to mietkatalog", () => {
|
||||
expect(content).toContain("Zurück zum Katalog");
|
||||
expect(content).toContain("/mietkatalog");
|
||||
});
|
||||
|
||||
it("should display equipment name", () => {
|
||||
expect(content).toContain("item.name");
|
||||
});
|
||||
|
||||
it("should display item code", () => {
|
||||
expect(content).toContain("item.code");
|
||||
expect(content).toContain("Artikelnummer");
|
||||
});
|
||||
|
||||
it("should display brand", () => {
|
||||
expect(content).toContain("item.brand");
|
||||
expect(content).toContain("Marke");
|
||||
});
|
||||
|
||||
it("should display description", () => {
|
||||
expect(content).toContain("item.description");
|
||||
});
|
||||
|
||||
it("should display specs table", () => {
|
||||
expect(content).toContain("item.specs");
|
||||
expect(content).toContain("Technische Daten");
|
||||
});
|
||||
|
||||
it("should display image with placeholder fallback", () => {
|
||||
expect(content).toContain("item.image");
|
||||
expect(content).toContain("📦");
|
||||
expect(content).toContain("Kein Bild verfügbar");
|
||||
});
|
||||
|
||||
it("should have Mietanfrage section", () => {
|
||||
expect(content).toContain("Mietanfrage");
|
||||
expect(content).toContain("Mietbeginn");
|
||||
expect(content).toContain("Mietende");
|
||||
});
|
||||
|
||||
it("should have quantity selector", () => {
|
||||
expect(content).toContain("quantity");
|
||||
expect(content).toContain("Anzahl");
|
||||
});
|
||||
|
||||
it("should have add to cart button", () => {
|
||||
expect(content).toContain("Zur Mietanfrage hinzufügen");
|
||||
expect(content).toContain("hms-btn-primary");
|
||||
});
|
||||
|
||||
it("should show ErrorState for non-existent ID", () => {
|
||||
expect(content).toContain("ErrorState");
|
||||
expect(content).toContain("nicht gefunden");
|
||||
});
|
||||
|
||||
it("should have related items section", () => {
|
||||
expect(content).toContain("relatedItems");
|
||||
expect(content).toContain("Ähnliche Geräte");
|
||||
expect(content).toContain("EquipmentCard");
|
||||
});
|
||||
|
||||
it("should use loading skeleton", () => {
|
||||
expect(content).toContain("hms-skeleton");
|
||||
expect(content).toContain("loading");
|
||||
});
|
||||
|
||||
it("should use TypeScript with lang=ts", () => {
|
||||
expect(content).toContain('<script setup lang="ts">');
|
||||
});
|
||||
|
||||
it("should have 18 equipment items in data", () => {
|
||||
expect(content).toContain("L-Acoustics K2");
|
||||
expect(content).toContain("Sennheiser EW-DX 835");
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
describe("Home Page (prototype port)", () => {
|
||||
const pagePath = resolve(__dirname, "../../pages/index.vue");
|
||||
const content = readFileSync(pagePath, "utf-8");
|
||||
|
||||
it("should have hms-hero section", () => {
|
||||
expect(content).toContain("hms-hero");
|
||||
expect(content).toContain("hms-hero-bg");
|
||||
expect(content).toContain("hms-hero-overlay");
|
||||
});
|
||||
|
||||
it("should have hero title with Veranstaltungstechnik text", () => {
|
||||
expect(content).toContain("Veranstaltungstechnik");
|
||||
expect(content).toContain("Professionelle Technik");
|
||||
});
|
||||
|
||||
it("should have Mietkatalog button", () => {
|
||||
expect(content).toContain("Mietkatalog");
|
||||
expect(content).toContain("hms-btn-primary");
|
||||
});
|
||||
|
||||
it("should have Beratung anfragen button", () => {
|
||||
expect(content).toContain("Beratung anfragen");
|
||||
expect(content).toContain("hms-btn-secondary");
|
||||
});
|
||||
|
||||
it("should display all 8 services", () => {
|
||||
expect(content).toContain("Vermietung");
|
||||
expect(content).toContain("Verkauf");
|
||||
expect(content).toContain("Personal");
|
||||
expect(content).toContain("Transport");
|
||||
expect(content).toContain("Lagerung");
|
||||
expect(content).toContain("Werkstatt");
|
||||
expect(content).toContain("Installation");
|
||||
expect(content).toContain("Booking");
|
||||
});
|
||||
|
||||
it("should use ServiceCard component", () => {
|
||||
expect(content).toContain("ServiceCard");
|
||||
});
|
||||
|
||||
it("should have hms-speaker-grid with 6 speakers", () => {
|
||||
expect(content).toContain("hms-speaker-grid");
|
||||
expect(content).toContain("hms-speaker-item");
|
||||
expect(content).toContain("SpeakerIcon");
|
||||
expect(content).toContain("L-Acoustics K2");
|
||||
expect(content).toContain("L-Acoustics KS28");
|
||||
expect(content).toContain("Robe Pointe");
|
||||
});
|
||||
|
||||
it("should have about section with stats", () => {
|
||||
expect(content).toContain("20+");
|
||||
expect(content).toContain("1.000+");
|
||||
expect(content).toContain("Jahre Erfahrung");
|
||||
expect(content).toContain("Geräte im Lager");
|
||||
});
|
||||
|
||||
it("should have CTA section with Katalog durchsuchen", () => {
|
||||
expect(content).toContain("Katalog durchsuchen");
|
||||
expect(content).toContain("Online-Mietkatalog");
|
||||
});
|
||||
|
||||
it("should use TypeScript with lang=ts", () => {
|
||||
expect(content).toContain('<script setup lang="ts">');
|
||||
});
|
||||
|
||||
it("should use hms-badge hms-badge-primary", () => {
|
||||
expect(content).toContain("hms-badge");
|
||||
expect(content).toContain("hms-badge-primary");
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
describe("Kontakt Page (prototype port)", () => {
|
||||
const pagePath = resolve(__dirname, "../../pages/kontakt.vue");
|
||||
const content = readFileSync(pagePath, "utf-8");
|
||||
|
||||
it("should have page title Kontakt", () => {
|
||||
expect(content).toContain("Kontakt");
|
||||
});
|
||||
|
||||
it("should have office address card with Leipheim", () => {
|
||||
expect(content).toContain("Grockelhofen 10");
|
||||
expect(content).toContain("89340 Leipheim");
|
||||
});
|
||||
|
||||
it("should have warehouse address with Ellzee", () => {
|
||||
expect(content).toContain("Zur Schönhalde 8");
|
||||
expect(content).toContain("89352 Ellzee");
|
||||
});
|
||||
|
||||
it("should have opening hours", () => {
|
||||
expect(content).toContain("Öffnungszeiten");
|
||||
expect(content).toContain("10:00 – 18:00");
|
||||
});
|
||||
|
||||
it("should have 2 contact persons", () => {
|
||||
expect(content).toContain("Leopold Hammerschmidt");
|
||||
expect(content).toContain("Andreas Mössle");
|
||||
});
|
||||
|
||||
it("should have form with name field (required)", () => {
|
||||
expect(content).toContain('id="name"');
|
||||
expect(content).toContain("Name");
|
||||
});
|
||||
|
||||
it("should have form with email field (required)", () => {
|
||||
expect(content).toContain('id="email"');
|
||||
expect(content).toContain("E-Mail");
|
||||
});
|
||||
|
||||
it("should have form with phone field", () => {
|
||||
expect(content).toContain('id="phone"');
|
||||
expect(content).toContain("Telefon");
|
||||
});
|
||||
|
||||
it("should have form with subject select", () => {
|
||||
expect(content).toContain('id="subject"');
|
||||
expect(content).toContain("Anliegen");
|
||||
});
|
||||
|
||||
it("should have form with message textarea (required)", () => {
|
||||
expect(content).toContain('id="message"');
|
||||
expect(content).toContain("Nachricht");
|
||||
});
|
||||
|
||||
it("should have privacy consent checkbox", () => {
|
||||
expect(content).toContain('type="checkbox"');
|
||||
expect(content).toContain("Datenschutzerklärung");
|
||||
});
|
||||
|
||||
it("should have submit button", () => {
|
||||
expect(content).toContain("Nachricht senden");
|
||||
expect(content).toContain("hms-btn-primary");
|
||||
});
|
||||
|
||||
it("should have email validation", () => {
|
||||
expect(content).toContain("validate");
|
||||
});
|
||||
|
||||
it("should have success state", () => {
|
||||
expect(content).toContain("Nachricht übermittelt");
|
||||
expect(content).toContain("Vielen Dank");
|
||||
});
|
||||
|
||||
it("should have hms-input class", () => {
|
||||
expect(content).toContain("hms-input");
|
||||
});
|
||||
|
||||
it("should use TypeScript with lang=ts", () => {
|
||||
expect(content).toContain('<script setup lang="ts">');
|
||||
});
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
describe("AppHeader Component (prototype port)", () => {
|
||||
const componentPath = resolve(__dirname, "../../components/AppHeader.vue");
|
||||
const content = readFileSync(componentPath, "utf-8");
|
||||
|
||||
it("should have hms-header class", () => {
|
||||
expect(content).toContain("hms-header");
|
||||
});
|
||||
|
||||
it("should have nav items: Home, Referenzen, Mietkatalog, Kontakt", () => {
|
||||
expect(content).toContain("Home");
|
||||
expect(content).toContain("Referenzen");
|
||||
expect(content).toContain("Mietkatalog");
|
||||
expect(content).toContain("Kontakt");
|
||||
});
|
||||
|
||||
it("should have Warenkorb button with hms-btn-primary", () => {
|
||||
expect(content).toContain("Warenkorb");
|
||||
expect(content).toContain("hms-btn-primary");
|
||||
});
|
||||
|
||||
it("should have hms-nav-btn class for nav items", () => {
|
||||
expect(content).toContain("hms-nav-btn");
|
||||
});
|
||||
|
||||
it("should have mobile burger menu with aria-expanded", () => {
|
||||
expect(content).toContain("aria-expanded");
|
||||
expect(content).toContain("mobileMenuOpen");
|
||||
});
|
||||
|
||||
it("should use HmsLogo component", () => {
|
||||
expect(content).toContain("HmsLogo");
|
||||
});
|
||||
|
||||
it("should have NuxtLink for navigation", () => {
|
||||
expect(content).toContain("NuxtLink");
|
||||
});
|
||||
|
||||
it("should have aria-current for active route", () => {
|
||||
expect(content).toContain("aria-current");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppFooter Component (prototype port)", () => {
|
||||
const componentPath = resolve(__dirname, "../../components/AppFooter.vue");
|
||||
const content = readFileSync(componentPath, "utf-8");
|
||||
|
||||
it("should have hms-footer class", () => {
|
||||
expect(content).toContain("hms-footer");
|
||||
});
|
||||
|
||||
it("should contain address with Leipheim", () => {
|
||||
expect(content).toContain("89340");
|
||||
expect(content).toContain("Leipheim");
|
||||
});
|
||||
|
||||
it("should contain phone number", () => {
|
||||
expect(content).toContain("tel:+498221204433");
|
||||
expect(content).toContain("204433");
|
||||
});
|
||||
|
||||
it("should contain email link info@hms-licht-ton.de", () => {
|
||||
expect(content).toContain("mailto:info@hms-licht-ton.de");
|
||||
});
|
||||
|
||||
it("should contain Impressum link", () => {
|
||||
expect(content).toContain("Impressum");
|
||||
expect(content).toContain("/impressum");
|
||||
});
|
||||
|
||||
it("should contain DSGVO link", () => {
|
||||
expect(content).toContain("DSGVO");
|
||||
expect(content).toContain("/datenschutz");
|
||||
});
|
||||
|
||||
it("should contain AGB Vermietung link", () => {
|
||||
expect(content).toContain("AGB Vermietung");
|
||||
expect(content).toContain("/agb-vermietung");
|
||||
});
|
||||
|
||||
it("should contain Admin-Login link", () => {
|
||||
expect(content).toContain("Admin-Login");
|
||||
expect(content).toContain("/admin");
|
||||
});
|
||||
|
||||
it("should have 4-column grid layout", () => {
|
||||
expect(content).toContain("lg:grid-cols-4");
|
||||
});
|
||||
|
||||
it("should include Facebook social icon link", () => {
|
||||
expect(content).toContain("facebook.com");
|
||||
});
|
||||
|
||||
it("should include Instagram social icon link", () => {
|
||||
expect(content).toContain("instagram.com");
|
||||
});
|
||||
|
||||
it("should use HmsLogo component", () => {
|
||||
expect(content).toContain("HmsLogo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Page (404, prototype port)", () => {
|
||||
const errorPath = resolve(__dirname, "../../error.vue");
|
||||
const content = readFileSync(errorPath, "utf-8");
|
||||
|
||||
it("should contain 'Seite nicht gefunden' for 404", () => {
|
||||
expect(content).toContain("Seite nicht gefunden");
|
||||
});
|
||||
|
||||
it("should contain 404 text", () => {
|
||||
expect(content).toContain("404");
|
||||
});
|
||||
|
||||
it("should contain link to home", () => {
|
||||
expect(content).toContain('to="/"');
|
||||
expect(content).toContain("Zur Startseite");
|
||||
});
|
||||
|
||||
it("should include noindex meta tag", () => {
|
||||
expect(content).toContain("noindex");
|
||||
expect(content).toContain("nofollow");
|
||||
});
|
||||
|
||||
it("should have hms-btn hms-btn-primary", () => {
|
||||
expect(content).toContain("hms-btn");
|
||||
expect(content).toContain("hms-btn-primary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HmsLogo Component (prototype port)", () => {
|
||||
const logoPath = resolve(__dirname, "../../components/HmsLogo.vue");
|
||||
const content = readFileSync(logoPath, "utf-8");
|
||||
|
||||
it("should render SVG element", () => {
|
||||
expect(content).toContain("<svg");
|
||||
});
|
||||
|
||||
it("should use orange accent color #EC6925", () => {
|
||||
expect(content).toContain("#EC6925");
|
||||
});
|
||||
|
||||
it("should have hms-logo-svg class", () => {
|
||||
expect(content).toContain("hms-logo-svg");
|
||||
});
|
||||
|
||||
it("should accept size prop with default 40", () => {
|
||||
expect(content).toContain("size");
|
||||
expect(content).toContain("40");
|
||||
});
|
||||
});
|
||||
|
||||
describe("robots.txt route", () => {
|
||||
const robotsPath = resolve(__dirname, "../../server/routes/robots.txt.ts");
|
||||
const content = readFileSync(robotsPath, "utf-8");
|
||||
|
||||
it("should contain User-agent: *", () => {
|
||||
expect(content).toContain("User-agent: *");
|
||||
});
|
||||
|
||||
it("should contain Disallow: /", () => {
|
||||
expect(content).toContain("Disallow: /");
|
||||
});
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
describe("Mietkatalog Page (prototype port)", () => {
|
||||
const pagePath = resolve(__dirname, "../../pages/mietkatalog.vue");
|
||||
const content = readFileSync(pagePath, "utf-8");
|
||||
|
||||
it("should have page title Mietkatalog", () => {
|
||||
expect(content).toContain("Mietkatalog");
|
||||
});
|
||||
|
||||
it("should have search input with hms-input", () => {
|
||||
expect(content).toContain('type="search"');
|
||||
expect(content).toContain("hms-input");
|
||||
expect(content).toContain("searchQuery");
|
||||
});
|
||||
|
||||
it("should have sort dropdown", () => {
|
||||
expect(content).toContain("sortBy");
|
||||
expect(content).toContain("name");
|
||||
expect(content).toContain("brand");
|
||||
expect(content).toContain("code");
|
||||
});
|
||||
|
||||
it("should have category filter chips with hms-chip", () => {
|
||||
expect(content).toContain("hms-chip");
|
||||
expect(content).toContain("activeCategory");
|
||||
expect(content).toContain("Tontechnik");
|
||||
expect(content).toContain("Lichttechnik");
|
||||
expect(content).toContain("Rigging");
|
||||
});
|
||||
|
||||
it("should show result count", () => {
|
||||
expect(content).toContain("filteredEquipment.length");
|
||||
expect(content).toContain("Gerät");
|
||||
expect(content).toContain("gefunden");
|
||||
});
|
||||
|
||||
it("should use LoadingSkeleton component", () => {
|
||||
expect(content).toContain("LoadingSkeleton");
|
||||
});
|
||||
|
||||
it("should use ErrorState component", () => {
|
||||
expect(content).toContain("ErrorState");
|
||||
});
|
||||
|
||||
it("should use EmptyState component", () => {
|
||||
expect(content).toContain("EmptyState");
|
||||
});
|
||||
|
||||
it("should render EquipmentCard for each item", () => {
|
||||
expect(content).toContain("EquipmentCard");
|
||||
expect(content).toContain("filteredEquipment");
|
||||
});
|
||||
|
||||
it("should have 18 equipment items", () => {
|
||||
expect(content).toContain("L-Acoustics K2");
|
||||
expect(content).toContain("Shure SM58");
|
||||
expect(content).toContain("Sennheiser EW-DX 835");
|
||||
});
|
||||
|
||||
it("should have Warenkorb button", () => {
|
||||
expect(content).toContain("Warenkorb ansehen");
|
||||
});
|
||||
|
||||
it("should use TypeScript with lang=ts", () => {
|
||||
expect(content).toContain('<script setup lang="ts">');
|
||||
});
|
||||
|
||||
it("should use hms-card for filter panel", () => {
|
||||
expect(content).toContain("hms-card");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EquipmentCard Component (prototype port)", () => {
|
||||
const cardPath = resolve(__dirname, "../../components/EquipmentCard.vue");
|
||||
const content = readFileSync(cardPath, "utf-8");
|
||||
|
||||
it("should have hms-eq-card class", () => {
|
||||
expect(content).toContain("hms-eq-card");
|
||||
});
|
||||
|
||||
it("should have image with fallback placeholder", () => {
|
||||
expect(content).toContain("item.image");
|
||||
expect(content).toContain("📦");
|
||||
});
|
||||
|
||||
it("should show category badge", () => {
|
||||
expect(content).toContain("hms-badge");
|
||||
expect(content).toContain("hms-badge-primary");
|
||||
expect(content).toContain("item.category");
|
||||
});
|
||||
|
||||
it("should display equipment name", () => {
|
||||
expect(content).toContain("item.name");
|
||||
});
|
||||
|
||||
it("should show truncated description", () => {
|
||||
expect(content).toContain("item.description");
|
||||
expect(content).toContain("line-clamp");
|
||||
});
|
||||
|
||||
it("should display item code", () => {
|
||||
expect(content).toContain("item.code");
|
||||
});
|
||||
|
||||
it("should emit add-to-cart", () => {
|
||||
expect(content).toContain("add-to-cart");
|
||||
});
|
||||
|
||||
it("should have Hinzufügen button", () => {
|
||||
expect(content).toContain("Hinzufügen");
|
||||
expect(content).toContain("hms-btn-ghost");
|
||||
});
|
||||
|
||||
it("should emit click", () => {
|
||||
expect(content).toContain("click");
|
||||
});
|
||||
|
||||
it("should use TypeScript with lang=ts", () => {
|
||||
expect(content).toContain('<script setup lang="ts">');
|
||||
});
|
||||
});
|
||||