feat: add MCP server with resources, tools, auth, and Traefik routing

- Add backend/app/mcp_server.py with FastMCP server (hms-cms)
- Resources: design-rules, page-structure, sync-status
- Tools: trigger_sync, get_sync_log, set_rentman_token, read_file,
  write_file, create_page, deploy, git_commit, git_status
- Bearer token auth via MCP_AUTH_TOKEN env var
- Mount MCP at /mcp with streamable HTTP transport
- Integrate session manager in FastAPI lifespan
- Add Traefik labels for external /mcp route on hms.media-on.de
- Add git, docker.io, curl to backend Dockerfile
- Mount repo root + docker socket in backend container
- Add mcp>=1.0.0 to requirements.txt
This commit is contained in:
Agent Zero
2026-07-12 15:58:22 +02:00
parent 00c23bc066
commit cd465e0564
124 changed files with 3309 additions and 225 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
.nuxt
.output
dist
.git
.gitignore
*.md
.env
.env.*
playwright-report
test-results
coverage
+32
View File
@@ -0,0 +1,32 @@
# 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
+21
View File
@@ -0,0 +1,21 @@
# --- Stage 1: Build ---
FROM node:20 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# --- Stage 2: Production ---
FROM node:20-slim AS production
WORKDIR /app
COPY --from=builder /app/.output ./.output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
+11
View File
@@ -0,0 +1,11 @@
<template>
<div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>
<script setup lang="ts">
// App root wraps all pages in default layout
</script>
+432
View File
@@ -0,0 +1,432 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ============================================
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;
--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); }
}
@@ -0,0 +1,427 @@
/* ============================================
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); }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+9
View File
@@ -0,0 +1,9 @@
<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>

After

Width:  |  Height:  |  Size: 503 B

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

After

Width:  |  Height:  |  Size: 503 B

@@ -0,0 +1,8 @@
export default defineEventHandler((event) => {
setHeader(event, "Content-Type", "text/plain; charset=utf-8");
return [
"User-agent: *",
"Disallow: /",
"",
].join("\n");
});
+178
View File
@@ -0,0 +1,178 @@
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 = [];
},
},
});
+65
View File
@@ -0,0 +1,65 @@
import type { Config } from "tailwindcss";
export default <Config>{
content: [
"./components/**/*.{vue,js,ts}",
"./layouts/**/*.vue",
"./pages/**/*.vue",
"./app.vue",
"./error.vue",
],
theme: {
extend: {
colors: {
bg: "#131313",
panel: "#1a1a1a",
surface: "#212121",
row: "#272727",
card: "#2d2d2d",
"border-default": "#444444a8",
secondary: "#656565",
primary: "#737a81",
text: "#ffffff",
"text-muted": "#d4d4d4",
accent: {
DEFAULT: "#FA5C01",
hover: "#d4581a",
light: "rgba(236, 105, 37, 0.08)",
border: "rgba(236, 105, 37, 0.25)",
dark: "#b8461a",
},
success: "#4ade80",
"success-bg": "rgba(74, 222, 128, 0.08)",
error: "#f87171",
"error-bg": "rgba(248, 113, 113, 0.08)",
warning: "#fbbf24",
info: "#60a5fa",
},
borderRadius: {
sm: "2px",
md: "3px",
lg: "4px",
xl: "4px",
full: "9999px",
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
},
boxShadow: {
sm: "0 1px 2px 0 rgb(0 0 0 / 0.3)",
md: "0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3)",
lg: "0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3)",
xl: "0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.3)",
},
transitionDuration: {
fast: "150ms",
base: "250ms",
slow: "400ms",
},
maxWidth: {
"7xl": "1280px",
},
},
},
plugins: [],
};
+102
View File
@@ -0,0 +1,102 @@
# 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`
+68
View File
@@ -0,0 +1,68 @@
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();
});
});
@@ -0,0 +1,54 @@
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$/);
}
});
});
@@ -0,0 +1,10 @@
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();
});
});
@@ -0,0 +1,74 @@
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");
});
});
+68
View File
@@ -0,0 +1,68 @@
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();
});
});
@@ -0,0 +1,74 @@
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();
});
});
@@ -0,0 +1,96 @@
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();
});
});
@@ -0,0 +1,27 @@
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");
});
});
@@ -0,0 +1,27 @@
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();
});
});
@@ -0,0 +1,76 @@
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+/);
}
});
});
@@ -0,0 +1,80 @@
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();
});
});
@@ -0,0 +1,101 @@
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");
});
});
@@ -0,0 +1,106 @@
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");
});
});
@@ -0,0 +1,83 @@
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");
});
});
@@ -0,0 +1,74 @@
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");
});
});
@@ -0,0 +1,84 @@
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">');
});
});
+167
View File
@@ -0,0 +1,167 @@
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: /");
});
});
@@ -0,0 +1,124 @@
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">');
});
});
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
describe("Referenzen Page (prototype port)", () => {
const pagePath = resolve(__dirname, "../../pages/referenzen.vue");
const content = readFileSync(pagePath, "utf-8");
it("should have page title Referenzen", () => {
expect(content).toContain("Referenzen");
});
it("should have hms-gallery grid", () => {
expect(content).toContain("hms-gallery");
});
it("should have 9 reference images", () => {
expect(content).toContain("ref1.jpg");
expect(content).toContain("ref9.jpg");
});
it("should have category filter chips with hms-chip", () => {
expect(content).toContain("hms-chip");
expect(content).toContain("Alle");
expect(content).toContain("Open-Air");
expect(content).toContain("Indoor");
expect(content).toContain("Rigging");
expect(content).toContain("Event");
});
it("should have filter logic with computed filteredImages", () => {
expect(content).toContain("filteredImages");
expect(content).toContain("filter");
expect(content).toContain("computed");
});
it("should have loading skeleton state", () => {
expect(content).toContain("hms-skeleton");
expect(content).toContain("loading");
});
it("should use ErrorState component", () => {
expect(content).toContain("ErrorState");
});
it("should use EmptyState component", () => {
expect(content).toContain("EmptyState");
});
it("should use TypeScript with lang=ts", () => {
expect(content).toContain('<script setup lang="ts">');
});
});
@@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
describe("Warenkorb Page (prototype port)", () => {
const pagePath = resolve(__dirname, "../../pages/warenkorb.vue");
const content = readFileSync(pagePath, "utf-8");
it("should have page title Mietanfrage", () => {
expect(content).toContain("Mietanfrage");
});
it("should use useCart composable", () => {
expect(content).toContain("useCart");
});
it("should have empty state with link to mietkatalog", () => {
expect(content).toContain("EmptyState");
expect(content).toContain("Warenkorb ist leer");
expect(content).toContain("/mietkatalog");
});
it("should have cart item list", () => {
expect(content).toContain("cartItems");
expect(content).toContain("v-for");
});
it("should have quantity stepper with increment and decrement", () => {
expect(content).toContain("incrementQuantity");
expect(content).toContain("decrementQuantity");
});
it("should display item quantity", () => {
expect(content).toContain("item.quantity");
});
it("should have remove button", () => {
expect(content).toContain("removeItem");
});
it("should have clear cart button", () => {
expect(content).toContain("clearCart");
expect(content).toContain("Warenkorb leeren");
});
it("should display total count", () => {
expect(content).toContain("totalCount");
});
it("should have request form with name field", () => {
expect(content).toContain('id="req-name"');
expect(content).toContain("Name");
});
it("should have request form with email field", () => {
expect(content).toContain('id="req-email"');
expect(content).toContain("E-Mail");
});
it("should have request form with event date field", () => {
expect(content).toContain('id="req-date"');
expect(content).toContain("Veranstaltungsdatum");
});
it("should have request form with location field", () => {
expect(content).toContain('id="req-location"');
expect(content).toContain("Veranstaltungsort");
});
it("should have submit button", () => {
expect(content).toContain("Anfrage absenden");
});
it("should have success state", () => {
expect(content).toContain("Mietanfrage übermittelt");
expect(content).toContain("Vielen Dank");
});
it("should have hms-card for items", () => {
expect(content).toContain("hms-card");
});
it("should use TypeScript with lang=ts", () => {
expect(content).toContain('<script setup lang="ts">');
});
});
@@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
describe("useApi composable", () => {
const path = resolve(__dirname, "../../composables/useApi.ts");
const content = readFileSync(path, "utf-8");
it("should export useApi function", () => {
expect(content).toContain("export function useApi");
});
it("should use useRuntimeConfig for apiBase", () => {
expect(content).toContain("useRuntimeConfig");
expect(content).toContain("apiBase");
});
it("should create $fetch client with baseURL", () => {
expect(content).toContain("$fetch.create");
expect(content).toContain("baseURL");
});
it("should export get, post, put, del methods", () => {
expect(content).toContain("async function get");
expect(content).toContain("async function post");
expect(content).toContain("async function put");
expect(content).toContain("async function del");
});
});
describe("useEquipment composable", () => {
const path = resolve(__dirname, "../../composables/useEquipment.ts");
const content = readFileSync(path, "utf-8");
it("should export EquipmentItem interface", () => {
expect(content).toContain("export interface EquipmentItem");
});
it("should export EquipmentDetail interface", () => {
expect(content).toContain("export interface EquipmentDetail");
});
it("should export PaginatedEquipment interface", () => {
expect(content).toContain("export interface PaginatedEquipment");
});
it("should export SortOption type", () => {
expect(content).toContain("export type SortOption");
});
it("should use useApi internally", () => {
expect(content).toContain("useApi");
});
it("should have list method with search, category, sort, page params", () => {
expect(content).toContain("async function list");
expect(content).toContain("search");
expect(content).toContain("category");
expect(content).toContain("sort");
expect(content).toContain("page");
});
it("should call /api/equipment for list", () => {
expect(content).toContain("/api/equipment");
expect(content).toContain("/api/equipment/categories");
expect(content).toContain("/api/equipment/${id}");
});
it("should have detail method", () => {
expect(content).toContain("async function detail");
});
it("should have categories method", () => {
expect(content).toContain("async function categories");
expect(content).toContain("/api/equipment/categories");
});
it("should return list, detail, categories from composable", () => {
expect(content).toContain("return");
expect(content).toContain("list");
expect(content).toContain("detail");
expect(content).toContain("categories");
});
});
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
export default defineConfig({
test: {
environment: "happy-dom",
globals: true,
include: ["tests/unit/**/*.test.ts"],
},
resolve: {
alias: {
"~": fileURLToPath(new URL("./", import.meta.url)),
"#app": fileURLToPath(new URL("./node_modules/nuxt/dist/app", import.meta.url)),
},
},
});