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