Compare commits
23 Commits
0f569f2c6e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 489419a7d4 | |||
| f815e64499 | |||
| e5606c64c5 | |||
| a08bdaa3b1 | |||
| 36a1ad0b84 | |||
| 80cb971308 | |||
| 6483453437 | |||
| 1221350ed6 | |||
| 620f7b3153 | |||
| cd465e0564 | |||
| 00c23bc066 | |||
| 5f67cc9140 | |||
| a6375567b0 | |||
| 9a269aa54f | |||
| 30db3491c0 | |||
| 4c63166384 | |||
| 63316f1e92 | |||
| 8dae2c0301 | |||
| 16fde9fb7d | |||
| e0e170fe6a | |||
| 90a7d7f2e0 | |||
| 786cb0c040 | |||
| 7b1bebe7da |
+1
-1
@@ -9,7 +9,7 @@ RENTMAN_API_TOKEN=
|
|||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
|
|
||||||
# --- Database ---
|
# --- Database ---
|
||||||
DATABASE_URL=postgresql://hms:hms@postgres:5432/hms
|
DATABASE_URL=postgresql+asyncpg://hms:hms@postgres:5432/hms
|
||||||
|
|
||||||
# --- Redis ---
|
# --- Redis ---
|
||||||
REDIS_URL=redis://redis:6379/0
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ htmlcov/
|
|||||||
node_modules/
|
node_modules/
|
||||||
.nuxt/
|
.nuxt/
|
||||||
dist/
|
dist/
|
||||||
|
images/
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
> **Projekt:** hms-licht-ton (ID 30)
|
> **Projekt:** hms-licht-ton (ID 30)
|
||||||
> **Stack:** Nuxt 3 (Frontend) + FastAPI (Backend) + PostgreSQL + Redis
|
> **Stack:** Nuxt 3 (Frontend) + FastAPI (Backend) + PostgreSQL + Redis
|
||||||
> **Deployment:** Coolify (Docker) auf coolify-01
|
> **Deployment:** Coolify (Docker) auf coolify-01
|
||||||
|
>
|
||||||
|
> **DESIGN-RICHTLINIEN:** `docs/design-rules.md` – VERBINDLICH für alle Erweiterungen!
|
||||||
|
> Jeder KI-Agent MUSS diese Regeln vor der Implementierung lesen und einhalten.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -2,9 +2,9 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies for asyncpg
|
# Install system dependencies for asyncpg + git + docker-cli (MCP tools)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
gcc libpq-dev && \
|
gcc libpq-dev git docker.io curl && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|||||||
+3
-4
@@ -2,7 +2,7 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
import bcrypt
|
||||||
from fastapi import Depends, HTTPException, status, Request
|
from fastapi import Depends, HTTPException, status, Request
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -12,17 +12,16 @@ from app.models.admin_user import AdminUser
|
|||||||
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
"""Verify a plain password against a hash."""
|
"""Verify a plain password against a hash."""
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
|
||||||
|
|
||||||
|
|
||||||
def get_password_hash(password: str) -> str:
|
def get_password_hash(password: str) -> str:
|
||||||
"""Hash a password using bcrypt."""
|
"""Hash a password using bcrypt."""
|
||||||
return pwd_context.hash(password)
|
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||||
|
|||||||
+12
-34
@@ -11,6 +11,7 @@ from app.cache import cache
|
|||||||
from app.routers import equipment, health, admin, rental_requests, contact
|
from app.routers import equipment, health, admin, rental_requests, contact
|
||||||
from app.services.sync_service import SyncService
|
from app.services.sync_service import SyncService
|
||||||
from app.services.email_service import EmailService
|
from app.services.email_service import EmailService
|
||||||
|
from app.mcp_server import get_mcp_app, mcp_session_manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -38,57 +39,34 @@ async def run_email_retry() -> None:
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan: init DB, start scheduler, connect cache."""
|
"""Application lifespan: init DB, start scheduler, connect cache."""
|
||||||
# Create tables (for dev/testing; production uses Alembic)
|
|
||||||
await init_db()
|
await init_db()
|
||||||
|
|
||||||
# Connect Redis cache
|
|
||||||
await cache.connect()
|
await cache.connect()
|
||||||
|
|
||||||
# Start APScheduler
|
_mcp_cm = mcp_session_manager.run()
|
||||||
scheduler.add_job(
|
await _mcp_cm.__aenter__()
|
||||||
run_equipment_sync,
|
|
||||||
trigger="cron",
|
scheduler.add_job(run_equipment_sync, trigger="cron", hour="*/6", id="equipment_sync", replace_existing=True)
|
||||||
hour="*/6",
|
scheduler.add_job(run_email_retry, trigger="cron", minute="*/15", id="email_retry", replace_existing=True)
|
||||||
id="equipment_sync",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
scheduler.add_job(
|
|
||||||
run_email_retry,
|
|
||||||
trigger="cron",
|
|
||||||
minute="*/15",
|
|
||||||
id="email_retry",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info("APScheduler started with equipment_sync (every 6h) and email_retry (every 15min)")
|
logger.info("APScheduler started")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
|
||||||
scheduler.shutdown(wait=False)
|
scheduler.shutdown(wait=False)
|
||||||
|
await _mcp_cm.__aexit__(None, None, None)
|
||||||
await cache.disconnect()
|
await cache.disconnect()
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(title="HMS Licht & Ton API", version="1.0.0", lifespan=lifespan)
|
||||||
title="HMS Licht & Ton API",
|
|
||||||
version="1.0.0",
|
|
||||||
lifespan=lifespan,
|
|
||||||
)
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
||||||
app.add_middleware(
|
app.add_middleware(CORSMiddleware, allow_origins=origins, allow_methods=["GET", "POST"], allow_headers=["*"], allow_credentials=True)
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=origins,
|
|
||||||
allow_methods=["GET", "POST"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Routers
|
|
||||||
app.include_router(equipment.router)
|
app.include_router(equipment.router)
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
app.include_router(rental_requests.router)
|
app.include_router(rental_requests.router)
|
||||||
app.include_router(contact.router)
|
app.include_router(contact.router)
|
||||||
|
|
||||||
|
app.mount("/mcp", get_mcp_app())
|
||||||
|
|||||||
@@ -0,0 +1,558 @@
|
|||||||
|
"""MCP Server for HMS CMS – provides resources and tools for AI agents.
|
||||||
|
|
||||||
|
Resources (read-only):
|
||||||
|
- design-rules: HMS design system as JSON
|
||||||
|
- page-structure: React pages and components map
|
||||||
|
- sync-status: current Rentman sync status
|
||||||
|
|
||||||
|
Tools (execute):
|
||||||
|
- trigger_sync: run Rentman equipment sync
|
||||||
|
- get_sync_log: fetch sync history
|
||||||
|
- set_rentman_token: update .env token + restart backend
|
||||||
|
- read_file: read frontend source file
|
||||||
|
- write_file: write frontend source file
|
||||||
|
- create_page: create React page with design validation
|
||||||
|
- deploy: rebuild + redeploy frontend container
|
||||||
|
- git_commit: commit all changes
|
||||||
|
- git_status: show working tree status
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
from app.database import async_session
|
||||||
|
from app.services.sync_service import SyncService
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------#
|
||||||
|
# Paths (mapped via docker-compose volumes)
|
||||||
|
# ---------------------------------------------------------------------------#
|
||||||
|
REPO_ROOT = Path("/data/repo")
|
||||||
|
FRONTEND_ROOT = REPO_ROOT / "frontend"
|
||||||
|
FRONTEND_SRC = FRONTEND_ROOT / "src"
|
||||||
|
ENV_FILE = REPO_ROOT / ".env"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------#
|
||||||
|
# Static design rules (derived from frontend/src/index.css)
|
||||||
|
# ---------------------------------------------------------------------------#
|
||||||
|
DESIGN_RULES: dict[str, Any] = {
|
||||||
|
"colors": {
|
||||||
|
"accent": "#FA5C01",
|
||||||
|
"bg": "#131313",
|
||||||
|
"panel": "#1a1a1a",
|
||||||
|
"surface": "#212121",
|
||||||
|
"text": "#ffffff",
|
||||||
|
"text_muted": "#d4d4d4",
|
||||||
|
"border": "#444444a8",
|
||||||
|
"secondary": "#656565",
|
||||||
|
},
|
||||||
|
"fonts": {"primary": "Inter", "weights": [400, 500, 600, 700, 800]},
|
||||||
|
"components": {
|
||||||
|
"hms-btn": "button class with variants: primary, secondary, ghost",
|
||||||
|
"hms-card": "card container with border, bg, hover effect",
|
||||||
|
"hms-badge": "badge with variants: primary, gray, success, error",
|
||||||
|
"hms-input": "input field with focus state",
|
||||||
|
"hms-chip": "filter chip with active state",
|
||||||
|
"hms-skeleton": "loading skeleton with shimmer animation",
|
||||||
|
"hms-speaker-grid": "responsive grid for equipment items",
|
||||||
|
"hms-gallery": "responsive gallery grid",
|
||||||
|
"hms-hero": "hero section with bg image and overlay",
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"max_width": "1280px",
|
||||||
|
"header_height": "4rem",
|
||||||
|
"radius": {"sm": 2, "md": 3, "lg": 4, "xl": 4, "full": 9999},
|
||||||
|
},
|
||||||
|
"rules": [
|
||||||
|
"Use Inter font for all text",
|
||||||
|
"Use #FA5C01 for all accent elements",
|
||||||
|
"Use hms-* CSS classes, do not invent new ones",
|
||||||
|
"All text in German",
|
||||||
|
"Use CSS variables (var(--text), var(--bg), etc.) not hardcoded colors",
|
||||||
|
"Use Tailwind utility classes for layout, hms-* classes for components",
|
||||||
|
"Images: use object-cover, lazy loading",
|
||||||
|
"Buttons: use hms-btn with variant classes",
|
||||||
|
"Cards: use hms-card class",
|
||||||
|
"Responsive: sm: breakpoint 640px, lg: breakpoint 1024px",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static page/component info (used when frontend source is not mounted)
|
||||||
|
_STATIC_PAGES = [
|
||||||
|
{"path": "/", "file": "src/pages/Index.tsx", "components": ["SpeakerIcon", "ServiceCard"]},
|
||||||
|
{"path": "/mietkatalog", "file": "src/pages/Mietkatalog.tsx", "components": ["EquipmentCard", "LoadingSkeleton", "ErrorState", "EmptyState"]},
|
||||||
|
{"path": "/mietkatalog/:id", "file": "src/pages/EquipmentDetail.tsx", "components": ["ErrorState"]},
|
||||||
|
{"path": "/kontakt", "file": "src/pages/Kontakt.tsx", "components": []},
|
||||||
|
{"path": "/referenzen", "file": "src/pages/Referenzen.tsx", "components": ["ErrorState", "EmptyState"]},
|
||||||
|
{"path": "/warenkorb", "file": "src/pages/Warenkorb.tsx", "components": ["EmptyState"]},
|
||||||
|
{"path": "/admin", "file": "src/pages/Admin.tsx", "components": ["Logo"]},
|
||||||
|
{"path": "/agb", "file": "src/pages/AGB.tsx", "components": ["LegalContentPage"]},
|
||||||
|
{"path": "/datenschutz", "file": "src/pages/Datenschutz.tsx", "components": ["LegalContentPage"]},
|
||||||
|
{"path": "/impressum", "file": "src/pages/Impressum.tsx", "components": ["LegalContentPage"]},
|
||||||
|
{"path": "/*", "file": "src/pages/ErrorPage.tsx", "components": ["Header", "Footer"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
_STATIC_COMPONENTS = [
|
||||||
|
{"name": "Header", "file": "src/components/Header.tsx", "props": []},
|
||||||
|
{"name": "Footer", "file": "src/components/Footer.tsx", "props": []},
|
||||||
|
{"name": "Logo", "file": "src/components/Logo.tsx", "props": ["size"]},
|
||||||
|
{"name": "EquipmentCard", "file": "src/components/EquipmentCard.tsx", "props": ["item", "onClick", "onAddToCart"]},
|
||||||
|
{"name": "LoadingSkeleton", "file": "src/components/LoadingSkeleton.tsx", "props": ["count"]},
|
||||||
|
{"name": "ErrorState", "file": "src/components/ErrorState.tsx", "props": ["title", "message", "onRetry"]},
|
||||||
|
{"name": "EmptyState", "file": "src/components/EmptyState.tsx", "props": ["icon", "title", "message", "actionLabel", "onAction"]},
|
||||||
|
{"name": "ServiceCard", "file": "src/components/ServiceCard.tsx", "props": ["icon", "title", "description"]},
|
||||||
|
{"name": "SpeakerIcon", "file": "src/components/SpeakerIcon.tsx", "props": ["type"]},
|
||||||
|
{"name": "LegalContentPage", "file": "src/components/LegalContentPage.tsx", "props": ["title", "content", "loading"]},
|
||||||
|
{"name": "Layout", "file": "src/components/Layout.tsx", "props": []},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------#
|
||||||
|
# FastMCP server instance
|
||||||
|
# ---------------------------------------------------------------------------#
|
||||||
|
# streamable_http_path="/" so that when mounted at /mcp the endpoint is /mcp/
|
||||||
|
# stateless_http=True: each request is independent (no session management)
|
||||||
|
# Disable DNS rebinding protection for Docker deployment
|
||||||
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
|
|
||||||
|
mcp = FastMCP("hms-cms", streamable_http_path="/", stateless_http=True, transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------#
|
||||||
|
# Bearer token authentication wrapper
|
||||||
|
# ---------------------------------------------------------------------------#
|
||||||
|
_MCP_TOKEN = os.environ.get("MCP_AUTH_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
class MCPAuthMiddleware:
|
||||||
|
"""ASGI middleware: validate Authorization: Bearer <token> for /mcp endpoint."""
|
||||||
|
|
||||||
|
def __init__(self, app):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] != "http":
|
||||||
|
return await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
# If no token configured, allow all (dev mode)
|
||||||
|
if not _MCP_TOKEN:
|
||||||
|
return await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
# Extract Authorization header
|
||||||
|
headers = dict(scope.get("headers", []))
|
||||||
|
auth_header = headers.get(b"authorization", b"").decode()
|
||||||
|
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
token = auth_header[7:]
|
||||||
|
if token == _MCP_TOKEN:
|
||||||
|
return await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
# Reject
|
||||||
|
await send({
|
||||||
|
"type": "http.response.start",
|
||||||
|
"status": 401,
|
||||||
|
"headers": [[b"content-type", b"application/json"], [b"www-authenticate", b"Bearer"]],
|
||||||
|
})
|
||||||
|
await send({
|
||||||
|
"type": "http.response.body",
|
||||||
|
"body": b'{"error": "Unauthorized: invalid or missing Bearer token"}',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def get_mcp_app():
|
||||||
|
"""Return the MCP ASGI app wrapped with auth middleware."""
|
||||||
|
return MCPAuthMiddleware(_mcp_streamable_app)
|
||||||
|
|
||||||
|
|
||||||
|
# Expose session manager for lifespan integration in main FastAPI app.
|
||||||
|
# FastAPI mount() does not call sub-app lifespans, so we must
|
||||||
|
# manually start/stop the session manager in the main app lifespan.
|
||||||
|
# Call streamable_http_app() first to lazily initialize the session manager.
|
||||||
|
_mcp_streamable_app = mcp.streamable_http_app()
|
||||||
|
mcp_session_manager = mcp._session_manager
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------#
|
||||||
|
# Resources (read-only)
|
||||||
|
# ---------------------------------------------------------------------------#
|
||||||
|
@mcp.resource("hms://design-rules")
|
||||||
|
async def design_rules() -> str:
|
||||||
|
"""Return the HMS design rules as structured JSON."""
|
||||||
|
return json.dumps(DESIGN_RULES, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("hms://page-structure")
|
||||||
|
async def page_structure() -> str:
|
||||||
|
"""List all React pages and their component usage."""
|
||||||
|
pages = list(_STATIC_PAGES)
|
||||||
|
components = list(_STATIC_COMPONENTS)
|
||||||
|
|
||||||
|
# Dynamic scan if frontend source is accessible
|
||||||
|
if FRONTEND_SRC.exists():
|
||||||
|
pages_dir = FRONTEND_SRC / "pages"
|
||||||
|
comp_dir = FRONTEND_SRC / "components"
|
||||||
|
|
||||||
|
if pages_dir.exists():
|
||||||
|
dyn_pages: list[dict] = []
|
||||||
|
for f in sorted(pages_dir.glob("*.tsx")):
|
||||||
|
content = f.read_text()
|
||||||
|
imports = re.findall(r"import\s+(\w+)\s+from\s+['\"]\.\.?/", content)
|
||||||
|
comp_imports = [imp for imp in imports if imp[0].isupper()]
|
||||||
|
stem = f.stem
|
||||||
|
if stem == "Index":
|
||||||
|
path = "/"
|
||||||
|
elif stem == "ErrorPage":
|
||||||
|
path = "/*"
|
||||||
|
elif stem == "EquipmentDetail":
|
||||||
|
path = "/mietkatalog/:id"
|
||||||
|
else:
|
||||||
|
path = f"/{stem.lower()}"
|
||||||
|
dyn_pages.append({
|
||||||
|
"path": path,
|
||||||
|
"file": f"src/pages/{f.name}",
|
||||||
|
"components": comp_imports,
|
||||||
|
})
|
||||||
|
if dyn_pages:
|
||||||
|
pages = dyn_pages
|
||||||
|
|
||||||
|
if comp_dir.exists():
|
||||||
|
dyn_comps: list[dict] = []
|
||||||
|
for f in sorted(comp_dir.glob("*.tsx")):
|
||||||
|
content = f.read_text()
|
||||||
|
props: list[str] = []
|
||||||
|
# Match interface Props { ... } or interface XxxProps { ... }
|
||||||
|
prop_match = re.search(r"interface\s+\w+\s*\{([^}]*)\}", content)
|
||||||
|
if prop_match:
|
||||||
|
props = re.findall(r"(\w+)\??\s*:", prop_match.group(1))
|
||||||
|
dyn_comps.append({
|
||||||
|
"name": f.stem,
|
||||||
|
"file": f"src/components/{f.name}",
|
||||||
|
"props": props,
|
||||||
|
})
|
||||||
|
if dyn_comps:
|
||||||
|
components = dyn_comps
|
||||||
|
|
||||||
|
return json.dumps({"pages": pages, "components": components}, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("hms://sync-status")
|
||||||
|
async def sync_status() -> str:
|
||||||
|
"""Return the current sync status (last sync, items, errors)."""
|
||||||
|
try:
|
||||||
|
async with async_session() as db:
|
||||||
|
svc = SyncService(db)
|
||||||
|
result = await svc.get_last_sync()
|
||||||
|
return json.dumps(result, indent=2, default=str, ensure_ascii=False)
|
||||||
|
except Exception as exc:
|
||||||
|
return json.dumps({"error": str(exc)}, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------#
|
||||||
|
# Tools (execute)
|
||||||
|
# ---------------------------------------------------------------------------#
|
||||||
|
@mcp.tool()
|
||||||
|
async def trigger_sync() -> str:
|
||||||
|
"""Trigger a Rentman equipment sync (like POST /api/admin/sync)."""
|
||||||
|
try:
|
||||||
|
async with async_session() as db:
|
||||||
|
svc = SyncService(db)
|
||||||
|
result = await svc.run_sync()
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
except Exception as exc:
|
||||||
|
return json.dumps({"error": str(exc)}, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_sync_log(page: int = 1, page_size: int = 20) -> str:
|
||||||
|
"""Return the sync history log.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Page number (1-based)
|
||||||
|
page_size: Items per page (max 100)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with async_session() as db:
|
||||||
|
svc = SyncService(db)
|
||||||
|
result = await svc.get_sync_log_paginated(page=page, page_size=page_size)
|
||||||
|
items = []
|
||||||
|
for log in result["items"]:
|
||||||
|
items.append({
|
||||||
|
"id": log.id,
|
||||||
|
"sync_type": log.sync_type,
|
||||||
|
"status": log.status,
|
||||||
|
"started_at": log.started_at.isoformat() if log.started_at else None,
|
||||||
|
"completed_at": log.completed_at.isoformat() if log.completed_at else None,
|
||||||
|
"items_processed": log.items_processed,
|
||||||
|
"items_failed": log.items_failed,
|
||||||
|
"error_message": log.error_message,
|
||||||
|
})
|
||||||
|
return json.dumps({
|
||||||
|
"items": items,
|
||||||
|
"total": result["total"],
|
||||||
|
"page": result["page"],
|
||||||
|
"page_size": result["page_size"],
|
||||||
|
"total_pages": result["total_pages"],
|
||||||
|
}, indent=2, default=str)
|
||||||
|
except Exception as exc:
|
||||||
|
return json.dumps({"error": str(exc)}, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def set_rentman_token(token: str) -> str:
|
||||||
|
"""Update the Rentman API Token in .env and restart the backend.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: New Rentman API token string
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not ENV_FILE.exists():
|
||||||
|
return json.dumps({"error": f".env file not found at {ENV_FILE}"})
|
||||||
|
|
||||||
|
content = ENV_FILE.read_text()
|
||||||
|
|
||||||
|
if "RENTMAN_API_TOKEN=" in content:
|
||||||
|
content = re.sub(
|
||||||
|
r"RENTMAN_API_TOKEN=.*",
|
||||||
|
f"RENTMAN_API_TOKEN={token}",
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
content += f"\nRENTMAN_API_TOKEN={token}\n"
|
||||||
|
|
||||||
|
ENV_FILE.write_text(content)
|
||||||
|
|
||||||
|
# Restart backend with a short delay so the response can be sent first
|
||||||
|
async def _restart() -> None:
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "compose", "restart", "backend"],
|
||||||
|
cwd=str(REPO_ROOT),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Backend restart failed: %s", exc)
|
||||||
|
|
||||||
|
asyncio.create_task(_restart())
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"message": "Token updated. Backend restart initiated (2s delay).",
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
return json.dumps({"error": str(exc)}, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def read_file(path: str) -> str:
|
||||||
|
"""Read a file from the frontend source.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Relative path to the file (relative to frontend/)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
full_path = FRONTEND_ROOT / path
|
||||||
|
if not full_path.exists() or not full_path.is_file():
|
||||||
|
return json.dumps({"error": f"File not found: {path}"})
|
||||||
|
# Security: ensure path stays within frontend dir
|
||||||
|
full_path = full_path.resolve()
|
||||||
|
if not str(full_path).startswith(str(FRONTEND_ROOT.resolve())):
|
||||||
|
return json.dumps({"error": "Path traversal blocked"})
|
||||||
|
return full_path.read_text()
|
||||||
|
except Exception as exc:
|
||||||
|
return json.dumps({"error": str(exc)}, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def write_file(path: str, content: str) -> str:
|
||||||
|
"""Write a file in the frontend source.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Relative path (relative to frontend/src/)
|
||||||
|
content: File content to write
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
full_path = (FRONTEND_SRC / path).resolve()
|
||||||
|
if not str(full_path).startswith(str(FRONTEND_SRC.resolve())):
|
||||||
|
return json.dumps({"error": "Path traversal blocked"})
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"path": str(full_path),
|
||||||
|
"bytes": len(content),
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
return json.dumps({"error": str(exc)}, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def create_page(name: str, content: str) -> str:
|
||||||
|
"""Create a new React page with design rule validation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Page name (e.g. 'UeberUns' creates src/pages/UeberUns.tsx)
|
||||||
|
content: Full TSX content of the page
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
issues: list[str] = []
|
||||||
|
|
||||||
|
# Validate: check for non-hms component classes
|
||||||
|
custom_classes = re.findall(r'className="([^"]*)"', content)
|
||||||
|
for cls_str in custom_classes:
|
||||||
|
for word in cls_str.split():
|
||||||
|
if word.startswith("btn") and not word.startswith("hms-btn"):
|
||||||
|
issues.append(f"Found '{word}' – use 'hms-btn' instead")
|
||||||
|
if word.startswith("card") and not word.startswith("hms-card"):
|
||||||
|
issues.append(f"Found '{word}' – use 'hms-card' instead")
|
||||||
|
if word.startswith("badge") and not word.startswith("hms-badge"):
|
||||||
|
issues.append(f"Found '{word}' – use 'hms-badge' instead")
|
||||||
|
|
||||||
|
# Validate: hardcoded hex colors (excluding #FA5C01 accent)
|
||||||
|
hex_colors = re.findall(r"#([0-9A-Fa-f]{6})\b", content)
|
||||||
|
for hc in hex_colors:
|
||||||
|
if hc.upper() not in ("FA5C01", "FFFFFF", "000000"):
|
||||||
|
issues.append(f"Hardcoded color #{hc} – use CSS variable instead")
|
||||||
|
|
||||||
|
# Validate: non-Inter font-family
|
||||||
|
font_match = re.search(r'font-family:\s*["\']?(?!Inter)["\']?', content)
|
||||||
|
if font_match:
|
||||||
|
issues.append("Non-Inter font detected – use Inter for all text")
|
||||||
|
|
||||||
|
# Write the file
|
||||||
|
safe_name = re.sub(r"[^a-zA-Z0-9_-]", "", name)
|
||||||
|
if not safe_name:
|
||||||
|
return json.dumps({"error": "Invalid page name"})
|
||||||
|
filename = f"{safe_name}.tsx"
|
||||||
|
file_path = FRONTEND_SRC / "pages" / filename
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path.write_text(content)
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"file": f"src/pages/{filename}",
|
||||||
|
"path": str(file_path),
|
||||||
|
"validation": {
|
||||||
|
"issues": issues,
|
||||||
|
"passed": len(issues) == 0,
|
||||||
|
},
|
||||||
|
}, indent=2, ensure_ascii=False)
|
||||||
|
except Exception as exc:
|
||||||
|
return json.dumps({"error": str(exc)}, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def deploy() -> str:
|
||||||
|
"""Build and deploy the frontend.
|
||||||
|
|
||||||
|
Runs: docker compose build --no-cache frontend
|
||||||
|
docker compose up -d --force-recreate frontend
|
||||||
|
docker compose restart frontend
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
commands = [
|
||||||
|
["docker", "compose", "build", "--no-cache", "frontend"],
|
||||||
|
["docker", "compose", "up", "-d", "--force-recreate", "frontend"],
|
||||||
|
["docker", "compose", "restart", "frontend"],
|
||||||
|
]
|
||||||
|
results = []
|
||||||
|
for cmd in commands:
|
||||||
|
r = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(REPO_ROOT),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
results.append({
|
||||||
|
"command": " ".join(cmd),
|
||||||
|
"returncode": r.returncode,
|
||||||
|
"stdout": r.stdout[-500:] if r.stdout else "",
|
||||||
|
"stderr": r.stderr[-500:] if r.stderr else "",
|
||||||
|
})
|
||||||
|
if r.returncode != 0:
|
||||||
|
return json.dumps({
|
||||||
|
"status": "failed",
|
||||||
|
"step": cmd[2],
|
||||||
|
"results": results,
|
||||||
|
}, indent=2)
|
||||||
|
|
||||||
|
return json.dumps({"status": "success", "results": results}, indent=2)
|
||||||
|
except Exception as exc:
|
||||||
|
return json.dumps({"error": str(exc)}, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def git_commit(message: str) -> str:
|
||||||
|
"""Commit all changes with a message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Git commit message
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["git", "add", "-A"],
|
||||||
|
cwd=str(REPO_ROOT),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return json.dumps({"error": "git add failed", "stderr": r.stderr})
|
||||||
|
|
||||||
|
r = subprocess.run(
|
||||||
|
["git", "commit", "-m", message],
|
||||||
|
cwd=str(REPO_ROOT),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
if "nothing to commit" in r.stdout:
|
||||||
|
return json.dumps({"status": "noop", "message": "Nothing to commit"})
|
||||||
|
return json.dumps({"error": "git commit failed", "stderr": r.stderr})
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"message": message,
|
||||||
|
"output": r.stdout.strip(),
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
return json.dumps({"error": str(exc)}, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def git_status() -> str:
|
||||||
|
"""Return the git working tree status."""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["git", "status", "--porcelain"],
|
||||||
|
cwd=str(REPO_ROOT),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return json.dumps({"error": r.stderr})
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for line in r.stdout.strip().split("\n"):
|
||||||
|
if line:
|
||||||
|
files.append({
|
||||||
|
"status": line[:2].strip(),
|
||||||
|
"file": line[3:].strip(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return json.dumps({"files": files, "clean": len(files) == 0}, indent=2)
|
||||||
|
except Exception as exc:
|
||||||
|
return json.dumps({"error": str(exc)}, indent=2)
|
||||||
@@ -15,12 +15,20 @@ class EquipmentCache(Base):
|
|||||||
description = Column(Text)
|
description = Column(Text)
|
||||||
specifications = Column(JSON)
|
specifications = Column(JSON)
|
||||||
images = Column(JSON)
|
images = Column(JSON)
|
||||||
|
update_hash = Column(String(128))
|
||||||
rental_price = Column(DECIMAL(10, 2))
|
rental_price = Column(DECIMAL(10, 2))
|
||||||
brand = Column(String(128))
|
brand = Column(String(128))
|
||||||
available = Column(Boolean, default=True)
|
available = Column(Boolean, default=True)
|
||||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def image_url(self) -> str | None:
|
||||||
|
"""Return local image URL if images exist, else None."""
|
||||||
|
if self.images and len(self.images) > 0:
|
||||||
|
return f"/api/equipment/{self.id}/image"
|
||||||
|
return None
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_equipment_category", "category"),
|
Index("idx_equipment_category", "category"),
|
||||||
Index("idx_equipment_name", "name"),
|
Index("idx_equipment_name", "name"),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Equipment API router: list, detail, categories."""
|
"""Equipment API router: list, detail, categories, image."""
|
||||||
|
import os
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy import select, func, or_
|
from sqlalchemy import select, func, or_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -10,6 +12,8 @@ from app.cache import cache
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/equipment", tags=["equipment"])
|
router = APIRouter(prefix="/api/equipment", tags=["equipment"])
|
||||||
|
|
||||||
|
IMAGES_DIR = "/data/images/equipment"
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=PaginatedResponse)
|
@router.get("", response_model=PaginatedResponse)
|
||||||
async def list_equipment(
|
async def list_equipment(
|
||||||
@@ -49,7 +53,7 @@ async def list_equipment(
|
|||||||
items = result.scalars().all()
|
items = result.scalars().all()
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"items": [EquipmentItem.model_validate(item) for item in items],
|
"items": [EquipmentItem.model_validate(item).model_dump() for item in items],
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
@@ -73,6 +77,15 @@ async def list_categories(db: AsyncSession = Depends(get_db)) -> Any:
|
|||||||
return categories
|
return categories
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{equipment_id}/image")
|
||||||
|
async def get_equipment_image(equipment_id: int, db: AsyncSession = Depends(get_db)) -> Any:
|
||||||
|
"""Serve the locally stored image for an equipment item."""
|
||||||
|
image_path = os.path.join(IMAGES_DIR, f"{equipment_id}.jpg")
|
||||||
|
if os.path.exists(image_path):
|
||||||
|
return FileResponse(image_path, media_type="image/jpeg")
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{equipment_id}", response_model=EquipmentDetail)
|
@router.get("/{equipment_id}", response_model=EquipmentDetail)
|
||||||
async def get_equipment(equipment_id: int, db: AsyncSession = Depends(get_db)) -> Any:
|
async def get_equipment(equipment_id: int, db: AsyncSession = Depends(get_db)) -> Any:
|
||||||
"""Return a single equipment detail by ID."""
|
"""Return a single equipment detail by ID."""
|
||||||
@@ -85,6 +98,6 @@ async def get_equipment(equipment_id: int, db: AsyncSession = Depends(get_db)) -
|
|||||||
item = result.scalar_one_or_none()
|
item = result.scalar_one_or_none()
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found")
|
||||||
response = EquipmentDetail.model_validate(item)
|
response = EquipmentDetail.model_validate(item).model_dump()
|
||||||
await cache.set(cache_key, response.model_dump(), ttl=3600)
|
await cache.set(cache_key, response, ttl=3600)
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -50,6 +50,65 @@ class RentmanService:
|
|||||||
offset += limit
|
offset += limit
|
||||||
return all_items
|
return all_items
|
||||||
|
|
||||||
|
async def get_file_url(self, file_id: str | int) -> str | None:
|
||||||
|
"""Fetch the S3 URL for a file from Rentman.
|
||||||
|
|
||||||
|
GET /files/{file_id} → response contains 'url' field with S3 link.
|
||||||
|
"""
|
||||||
|
url = f"{self._base_url}/files/{file_id}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
resp = await client.get(url, headers=self._headers())
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
file_data = data.get("data", data)
|
||||||
|
file_url = file_data.get("url")
|
||||||
|
if file_url:
|
||||||
|
return file_url
|
||||||
|
logger.warning("No url field in file response for file_id=%s", file_id)
|
||||||
|
return None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to fetch file URL for file_id=%s: %s", file_id, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def transform_equipment(self, raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Map a raw Rentman equipment object to equipment_cache schema.
|
||||||
|
|
||||||
|
Uses 'image' (singular) field which contains a relative path like '/files/3173'.
|
||||||
|
Fetches the actual S3 URL via GET /files/{file_id}.
|
||||||
|
"""
|
||||||
|
image_path = raw.get("image")
|
||||||
|
image_urls: list[str] = []
|
||||||
|
|
||||||
|
if image_path and isinstance(image_path, str):
|
||||||
|
# Extract file_id from path like /files/3173
|
||||||
|
match = re.search(r"/files/(\d+)", image_path)
|
||||||
|
if match:
|
||||||
|
file_id = match.group(1)
|
||||||
|
s3_url = await self.get_file_url(file_id)
|
||||||
|
if s3_url:
|
||||||
|
image_urls = [s3_url]
|
||||||
|
else:
|
||||||
|
logger.debug("Could not extract file_id from image path: %s", image_path)
|
||||||
|
|
||||||
|
group = raw.get("equipment_group") or {}
|
||||||
|
category = group.get("name", "") if isinstance(group, dict) else str(group or "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rentman_id": str(raw.get("id", "")),
|
||||||
|
"name": raw.get("name", ""),
|
||||||
|
"number": raw.get("number") or raw.get("code", ""),
|
||||||
|
"category": category,
|
||||||
|
"subcategory": raw.get("subcategory", ""),
|
||||||
|
"description": raw.get("description", ""),
|
||||||
|
"specifications": raw.get("specifications", {}),
|
||||||
|
"images": image_urls,
|
||||||
|
"rental_price": raw.get("rental_price"),
|
||||||
|
"brand": raw.get("brand", ""),
|
||||||
|
"available": raw.get("available", True),
|
||||||
|
"update_hash": raw.get("updateHash", ""),
|
||||||
|
}
|
||||||
|
|
||||||
async def create_project_request(self, payload: dict[str, Any]) -> dict[str, Any]:
|
async def create_project_request(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""POST /projectrequests to create a new project request in Rentman."""
|
"""POST /projectrequests to create a new project request in Rentman."""
|
||||||
url = f"{self._base_url}/projectrequests"
|
url = f"{self._base_url}/projectrequests"
|
||||||
@@ -68,35 +127,6 @@ class RentmanService:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def transform_equipment(raw: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Map a raw Rentman equipment object to equipment_cache schema."""
|
|
||||||
images = raw.get("images") or raw.get("files") or []
|
|
||||||
if isinstance(images, list):
|
|
||||||
image_urls = [
|
|
||||||
img.get("url", img.get("filename", "")) if isinstance(img, dict) else str(img)
|
|
||||||
for img in images
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
image_urls = []
|
|
||||||
|
|
||||||
group = raw.get("equipment_group") or {}
|
|
||||||
category = group.get("name", "") if isinstance(group, dict) else str(group or "")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"rentman_id": str(raw.get("id", "")),
|
|
||||||
"name": raw.get("name", ""),
|
|
||||||
"number": raw.get("number") or raw.get("code", ""),
|
|
||||||
"category": category,
|
|
||||||
"subcategory": raw.get("subcategory", ""),
|
|
||||||
"description": raw.get("description", ""),
|
|
||||||
"specifications": raw.get("specifications", {}),
|
|
||||||
"images": image_urls,
|
|
||||||
"rental_price": raw.get("rental_price"),
|
|
||||||
"brand": raw.get("brand", ""),
|
|
||||||
"available": raw.get("available", True),
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_project_request_payload(data: dict[str, Any]) -> dict[str, Any]:
|
def build_project_request_payload(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Map frontend rental request data to Rentman POST /projectrequests payload."""
|
"""Map frontend rental request data to Rentman POST /projectrequests payload."""
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Equipment sync service: import from Rentman, upsert into DB, invalidate cache."""
|
"""Equipment sync service: import from Rentman, upsert into DB, invalidate cache."""
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
import os
|
||||||
|
import httpx
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -11,6 +13,8 @@ from app.cache import cache
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
IMAGES_DIR = "/data/images/equipment"
|
||||||
|
|
||||||
|
|
||||||
class SyncService:
|
class SyncService:
|
||||||
"""Orchestrates equipment import from Rentman into the local database."""
|
"""Orchestrates equipment import from Rentman into the local database."""
|
||||||
@@ -20,19 +24,21 @@ class SyncService:
|
|||||||
self.rentman = rentman or RentmanService()
|
self.rentman = rentman or RentmanService()
|
||||||
|
|
||||||
async def run_sync(self) -> dict[str, Any]:
|
async def run_sync(self) -> dict[str, Any]:
|
||||||
"""Execute a full equipment sync.
|
"""Execute an incremental equipment sync.
|
||||||
|
|
||||||
1. Create sync_log entry (status=running)
|
1. Create sync_log entry (status=running)
|
||||||
2. Paginate GET /equipment from Rentman
|
2. Paginate GET /equipment from Rentman
|
||||||
3. Upsert each item into equipment_cache
|
3. Compare updateHash with DB, only process changed items
|
||||||
4. Invalidate Redis cache (equipment:*)
|
4. Download images only for changed items
|
||||||
5. Update sync_log (status=completed or failed)
|
5. Mark missing items as unavailable
|
||||||
|
6. Invalidate Redis cache (equipment:*)
|
||||||
|
7. Update sync_log (status=completed or failed)
|
||||||
Returns dict with sync_id, items_processed, status.
|
Returns dict with sync_id, items_processed, status.
|
||||||
"""
|
"""
|
||||||
log_entry = SyncLog(
|
log_entry = SyncLog(
|
||||||
sync_type="equipment",
|
sync_type="equipment",
|
||||||
status="running",
|
status="running",
|
||||||
started_at=datetime.now(timezone.utc),
|
started_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
self.db.add(log_entry)
|
self.db.add(log_entry)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
@@ -44,16 +50,53 @@ class SyncService:
|
|||||||
error_message: str | None = None
|
error_message: str | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Ensure images directory exists
|
||||||
|
os.makedirs(IMAGES_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# Fetch all equipment from Rentman
|
||||||
all_equipment = await self.rentman.get_all_equipment(limit=100)
|
all_equipment = await self.rentman.get_all_equipment(limit=100)
|
||||||
|
|
||||||
|
# Build set of rentman_ids from API for availability check
|
||||||
|
api_rentman_ids = set()
|
||||||
|
|
||||||
|
# Load all existing equipment from DB for hash comparison
|
||||||
|
result = await self.db.execute(select(EquipmentCache))
|
||||||
|
existing_items = {item.rentman_id: item for item in result.scalars().all()}
|
||||||
|
|
||||||
for raw_item in all_equipment:
|
for raw_item in all_equipment:
|
||||||
|
rentman_id = str(raw_item.get("id", ""))
|
||||||
|
api_rentman_ids.add(rentman_id)
|
||||||
|
new_hash = raw_item.get("updateHash", "")
|
||||||
|
|
||||||
|
existing = existing_items.get(rentman_id)
|
||||||
|
|
||||||
|
# Skip if hash unchanged and item exists
|
||||||
|
if existing and existing.update_hash == new_hash and new_hash:
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
transformed = RentmanService.transform_equipment(raw_item)
|
transformed = await self.rentman.transform_equipment(raw_item)
|
||||||
await self._upsert_equipment(transformed)
|
await self._upsert_equipment(transformed)
|
||||||
|
|
||||||
|
# Download image if S3 URL available
|
||||||
|
if transformed.get("images"):
|
||||||
|
await self._download_image(
|
||||||
|
transformed["rentman_id"],
|
||||||
|
transformed["images"][0],
|
||||||
|
existing.id if existing else None,
|
||||||
|
)
|
||||||
|
|
||||||
items_processed += 1
|
items_processed += 1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to upsert equipment item: %s", exc)
|
logger.warning("Failed to upsert equipment item %s: %s", rentman_id, exc)
|
||||||
items_failed += 1
|
items_failed += 1
|
||||||
|
|
||||||
|
# Mark missing items as unavailable
|
||||||
|
for rentman_id, existing in existing_items.items():
|
||||||
|
if rentman_id not in api_rentman_ids and existing.available:
|
||||||
|
existing.available = False
|
||||||
|
logger.info("Marked equipment %s as unavailable (not in API)", rentman_id)
|
||||||
|
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
|
|
||||||
# Invalidate Redis cache
|
# Invalidate Redis cache
|
||||||
@@ -70,7 +113,7 @@ class SyncService:
|
|||||||
log_entry.items_processed = items_processed
|
log_entry.items_processed = items_processed
|
||||||
log_entry.items_failed = items_failed
|
log_entry.items_failed = items_failed
|
||||||
log_entry.error_message = error_message
|
log_entry.error_message = error_message
|
||||||
log_entry.completed_at = datetime.now(timezone.utc)
|
log_entry.completed_at = datetime.utcnow()
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -95,6 +138,7 @@ class SyncService:
|
|||||||
existing.description = data.get("description", "")
|
existing.description = data.get("description", "")
|
||||||
existing.specifications = data.get("specifications")
|
existing.specifications = data.get("specifications")
|
||||||
existing.images = data.get("images")
|
existing.images = data.get("images")
|
||||||
|
existing.update_hash = data.get("update_hash")
|
||||||
existing.rental_price = data.get("rental_price")
|
existing.rental_price = data.get("rental_price")
|
||||||
existing.brand = data.get("brand", "")
|
existing.brand = data.get("brand", "")
|
||||||
existing.available = data.get("available", True)
|
existing.available = data.get("available", True)
|
||||||
@@ -108,12 +152,49 @@ class SyncService:
|
|||||||
description=data.get("description", ""),
|
description=data.get("description", ""),
|
||||||
specifications=data.get("specifications"),
|
specifications=data.get("specifications"),
|
||||||
images=data.get("images"),
|
images=data.get("images"),
|
||||||
|
update_hash=data.get("update_hash"),
|
||||||
rental_price=data.get("rental_price"),
|
rental_price=data.get("rental_price"),
|
||||||
brand=data.get("brand", ""),
|
brand=data.get("brand", ""),
|
||||||
available=data.get("available", True),
|
available=data.get("available", True),
|
||||||
)
|
)
|
||||||
self.db.add(new_item)
|
self.db.add(new_item)
|
||||||
|
|
||||||
|
async def _download_image(self, rentman_id: str, s3_url: str, db_id: int | None) -> None:
|
||||||
|
"""Download image from S3 URL and save locally.
|
||||||
|
|
||||||
|
File path: /data/images/equipment/{db_id or rentman_id}.jpg
|
||||||
|
"""
|
||||||
|
# Determine the file identifier - prefer DB id if available
|
||||||
|
# We need to flush to get the ID for new items
|
||||||
|
if db_id is None:
|
||||||
|
await self.db.flush()
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(EquipmentCache).where(EquipmentCache.rentman_id == rentman_id)
|
||||||
|
)
|
||||||
|
item = result.scalar_one_or_none()
|
||||||
|
if item:
|
||||||
|
db_id = item.id
|
||||||
|
|
||||||
|
if db_id is None:
|
||||||
|
logger.warning("Could not determine DB id for image download: %s", rentman_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
image_path = os.path.join(IMAGES_DIR, f"{db_id}.jpg")
|
||||||
|
|
||||||
|
# Skip if already exists
|
||||||
|
if os.path.exists(image_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
resp = await client.get(s3_url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
with open(image_path, "wb") as f:
|
||||||
|
f.write(resp.content)
|
||||||
|
logger.info("Downloaded image for equipment %s -> %s", db_id, image_path)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to download image for %s: %s", rentman_id, exc)
|
||||||
|
|
||||||
async def get_last_sync(self) -> dict[str, Any]:
|
async def get_last_sync(self) -> dict[str, Any]:
|
||||||
"""Return the most recent sync_log entry summary."""
|
"""Return the most recent sync_log entry summary."""
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ sqlalchemy[asyncio]>=2.0.30
|
|||||||
asyncpg>=0.29.0
|
asyncpg>=0.29.0
|
||||||
aiosqlite>=0.20.0
|
aiosqlite>=0.20.0
|
||||||
redis>=5.0.0
|
redis>=5.0.0
|
||||||
pydantic>=2.7.0
|
pydantic[email]>=2.7.0
|
||||||
pydantic-settings>=2.3.0
|
pydantic-settings>=2.3.0
|
||||||
python-jose[cryptography]>=3.3.0
|
python-jose[cryptography]>=3.3.0
|
||||||
passlib[bcrypt]>=1.7.4
|
passlib[bcrypt]>=1.7.4
|
||||||
@@ -16,3 +16,4 @@ pytest>=8.0.0
|
|||||||
pytest-asyncio>=0.23.0
|
pytest-asyncio>=0.23.0
|
||||||
pytest-cov>=5.0.0
|
pytest-cov>=5.0.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
|
mcp>=1.0.0
|
||||||
|
|||||||
+27
-18
@@ -1,34 +1,32 @@
|
|||||||
services:
|
services:
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
ports:
|
labels:
|
||||||
- "3000:3000"
|
- traefik.http.services.https-0-wvus7va5u0f9dmg27ggca7rl-frontend.loadbalancer.server.port=3000
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
|
||||||
- NUXT_PUBLIC_API_BASE=${NUXT_PUBLIC_API_BASE:-http://backend:8000}
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- curl -sf http://localhost:3000/ || exit 1
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 15s
|
start_period: 15s
|
||||||
networks:
|
networks:
|
||||||
- hms-network
|
- hms-network
|
||||||
|
- coolify
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=${DATABASE_URL:-postgresql://hms:hms@postgres:5432/hms}
|
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://hms:hms@postgres:5432/hms}
|
||||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
|
||||||
- RENTMAN_API_TOKEN=${RENTMAN_API_TOKEN}
|
- RENTMAN_API_TOKEN=${RENTMAN_API_TOKEN}
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
@@ -40,16 +38,22 @@ services:
|
|||||||
- CORS_ORIGINS=${CORS_ORIGINS:-https://hms.media-on.de,http://localhost:3000}
|
- CORS_ORIGINS=${CORS_ORIGINS:-https://hms.media-on.de,http://localhost:3000}
|
||||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||||
|
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN:-}
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
test:
|
||||||
|
- CMD
|
||||||
|
- python
|
||||||
|
- -c
|
||||||
|
- import urllib.request; urllib.request.urlopen("http://localhost:8000/api/health")
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
networks:
|
networks:
|
||||||
- hms-network
|
- hms-network
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
volumes:
|
volumes:
|
||||||
@@ -58,34 +62,39 @@ services:
|
|||||||
- POSTGRES_USER=hms
|
- POSTGRES_USER=hms
|
||||||
- POSTGRES_PASSWORD=hms
|
- POSTGRES_PASSWORD=hms
|
||||||
- POSTGRES_DB=hms
|
- POSTGRES_DB=hms
|
||||||
|
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U hms -d hms"]
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- pg_isready -U hms -d hms
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 5s
|
start_period: 5s
|
||||||
networks:
|
networks:
|
||||||
- hms-network
|
- hms-network
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test:
|
||||||
|
- CMD
|
||||||
|
- redis-cli
|
||||||
|
- ping
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 5s
|
start_period: 5s
|
||||||
networks:
|
networks:
|
||||||
- hms-network
|
- hms-network
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
hms-network:
|
hms-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
coolify:
|
||||||
|
external: true
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data: null
|
||||||
redis_data:
|
redis_data: null
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
- NUXT_PUBLIC_API_BASE=/api
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.middlewares.hms-gzip.compress=true"
|
||||||
|
- "traefik.http.middlewares.hms-redirect.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.routers.hms-http.entryPoints=http"
|
||||||
|
- "traefik.http.routers.hms-http.middlewares=hms-redirect"
|
||||||
|
- "traefik.http.routers.hms-http.rule=Host(`hms.media-on.de`) && PathPrefix(`/`)"
|
||||||
|
- "traefik.http.routers.hms-https.entryPoints=https"
|
||||||
|
- "traefik.http.routers.hms-https.middlewares=hms-gzip"
|
||||||
|
- "traefik.http.routers.hms-https.rule=Host(`hms.media-on.de`) && PathPrefix(`/`)"
|
||||||
|
- "traefik.http.routers.hms-https.tls=true"
|
||||||
|
- "traefik.http.routers.hms-https.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.hms.loadbalancer.server.port=3000"
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- hms-network
|
||||||
|
- coolify
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://hms:hms@postgres:5432/hms}
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
|
||||||
|
- RENTMAN_API_TOKEN=${RENTMAN_API_TOKEN}
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- SMTP_HOST=${SMTP_HOST}
|
||||||
|
- SMTP_PORT=${SMTP_PORT:-587}
|
||||||
|
- SMTP_USER=${SMTP_USER}
|
||||||
|
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||||
|
- SMTP_FROM=${SMTP_FROM:-info@hms-licht-ton.de}
|
||||||
|
- CORS_ORIGINS=${CORS_ORIGINS:-https://hms.media-on.de,http://localhost:3000}
|
||||||
|
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- ./images:/data/images
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- hms-network
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=hms
|
||||||
|
- POSTGRES_PASSWORD=hms
|
||||||
|
- POSTGRES_DB=hms
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U hms -d hms"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
networks:
|
||||||
|
- hms-network
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
networks:
|
||||||
|
- hms-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
hms-network:
|
||||||
|
driver: bridge
|
||||||
|
coolify:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
# HMS Licht & Ton – Design-Richtlinien für KI-Agenten
|
||||||
|
|
||||||
|
> **VERBINDLICH:** Diese Regeln MÜSSEN bei jeder Erweiterung der Website eingehalten werden.
|
||||||
|
> Abweichungen sind Fehler. Der freigegebene Prototyp ist die Quelle der Wahrheit.
|
||||||
|
> Prototyp: `https://webspace.media-on.de/hms-prototype/index.html?v=8`
|
||||||
|
> Referenz-Dateien: `docs/prototype-app.js`, `docs/prototype-index.html`, `frontend/assets/css/prototype-style.css`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Farbsystem (Dark Theme + Orange Akzent)
|
||||||
|
|
||||||
|
### Hauptfarben (Agent Zero Dark Theme Grautöne)
|
||||||
|
| Token | Hex | Verwendung |
|
||||||
|
|-------|-----|------------|
|
||||||
|
| `--bg` | `#131313` | Seiten-Hintergrund |
|
||||||
|
| `--panel` | `#1a1a1a` | Panels, Cards, Header |
|
||||||
|
| `--surface` | `#212121` | Input-Felder, Surface-Level |
|
||||||
|
| `--row` | `#272727` | List-Items, Zwischenebenen |
|
||||||
|
| `--card` | `#2d2d2d` | Card-Hintergrund |
|
||||||
|
| `--border` | `#444444a8` | Borders (mit Alpha) |
|
||||||
|
| `--border-strong` | `#555555` | Strong Borders (Inputs) |
|
||||||
|
| `--secondary` | `#656565` | Sekundärfarbe, Icons, grauer Text |
|
||||||
|
| `--primary` | `#737a81` | Primärfarbe (leicht bläuliches Grau) |
|
||||||
|
| `--text` | `#ffffff` | Haupttext |
|
||||||
|
| `--text-muted` | `#d4d4d4` | Gedimmter Text |
|
||||||
|
|
||||||
|
### Orange Akzent (DEZENT – keine großen Flächen!)
|
||||||
|
| Token | Wert | Verwendung |
|
||||||
|
|-------|------|------------|
|
||||||
|
| `--color-accent` | `#EC6925` | Buttons, Badges, Text-Akzente, Logo-Border |
|
||||||
|
| `--color-accent-hover` | `#d4581a` | Button Hover |
|
||||||
|
| `--color-accent-light` | `rgba(236, 105, 37, 0.08)` | Badge-Hintergrund, Nav-Active |
|
||||||
|
| `--color-accent-border` | `rgba(236, 105, 37, 0.25)` | Card Hover Border |
|
||||||
|
| `--color-accent-dark` | `#b8461a` | Dunkle Variante |
|
||||||
|
|
||||||
|
### Status-Farben
|
||||||
|
| Token | Hex/RGBA | Verwendung |
|
||||||
|
|-------|----------|------------|
|
||||||
|
| `--color-success` | `#4ade80` | Erfolg-Meldungen |
|
||||||
|
| `--color-success-bg` | `rgba(74, 222, 128, 0.08)` | Erfolg-Hintergrund |
|
||||||
|
| `--color-error` | `#f87171` | Fehler, Pflichtfelder |
|
||||||
|
| `--color-error-bg` | `rgba(248, 113, 113, 0.08)` | Fehler-Hintergrund |
|
||||||
|
| `--color-warning` | `#fbbf24` | Warnungen |
|
||||||
|
| `--color-info` | `#60a5fa` | Info |
|
||||||
|
|
||||||
|
### FARB-REGELN
|
||||||
|
- **Orange NIEMALS als große Fläche** – nur für Buttons, Badges, kleine Text-Highlights, Border
|
||||||
|
- **Hintergründe IMMER Grautöne** (#131313 bis #2d2d2d)
|
||||||
|
- **Text IMMER `var(--text)` (#ffffff) oder `var(--text-muted)` (#d4d4d4)**
|
||||||
|
- **Sekundärer Text `var(--secondary)` (#656565)** für sehr gedimmte Infos
|
||||||
|
- **Inline Styles mit CSS Variablen:** `style="color: var(--text)"`, `style="background: var(--panel)"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Typography
|
||||||
|
- **Font Family:** Inter (Google Fonts), Weights: 400, 500, 600, 700, 800
|
||||||
|
- **Größen:** Tailwind Utilities (`text-xs`, `text-sm`, `text-base`, `text-lg`, `text-xl`, `text-2xl`, `text-3xl`, `text-4xl`, `text-5xl`, `text-6xl`)
|
||||||
|
- **Headings:** `font-bold` oder `font-semibold`, `leading-tight` für große Headlines
|
||||||
|
- **Body:** `leading-relaxed` für längere Texte
|
||||||
|
- **Line Height:** 1.6 (Body), 1.1-1.3 (Headings)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Layout-System
|
||||||
|
|
||||||
|
### Container
|
||||||
|
- **Max-Width:** `max-w-7xl` (1280px)
|
||||||
|
- **Padding:** `px-4 sm:px-6 lg:px-8`
|
||||||
|
- **Center:** `mx-auto`
|
||||||
|
|
||||||
|
### Sections
|
||||||
|
- **Vertical Padding:** `py-12 sm:py-16` (Standard), `py-16 sm:py-20` (große Sections), `py-20 sm:py-32` (Hero)
|
||||||
|
- **Background Alternation:** `var(--bg)` → `var(--panel)` → `var(--bg)` für visuelle Trennung
|
||||||
|
- **Section Dividers:** `.hms-section-divider` (subtiler Gradient)
|
||||||
|
|
||||||
|
### Grid
|
||||||
|
- **Cards Grid:** `grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6`
|
||||||
|
- **Speaker Grid:** `.hms-speaker-grid` (auto-fill, minmax 160px)
|
||||||
|
- **Gallery Grid:** `.hms-gallery` (auto-fill, minmax 300px)
|
||||||
|
- **2-Spalten Layout:** `grid lg:grid-cols-2 gap-8` oder `gap-12`
|
||||||
|
|
||||||
|
### Responsive Breakpoints
|
||||||
|
- `sm:` 640px (Tablet Portrait)
|
||||||
|
- `md:` 768px (Desktop Nav visible, Mobile Burger hidden)
|
||||||
|
- `lg:` 1024px (Desktop, 3-4 Column Grids)
|
||||||
|
- **Mobile-First:** Alle Layouts starten 1-Spalte
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. CSS Komponenten-Klassen (hms-*)
|
||||||
|
|
||||||
|
### ÜBERSICHT: Alle hms-* Klassen aus prototype-style.css
|
||||||
|
Diese Klassen sind in `assets/css/main.css` definiert und MÜSSEN verwendet werden.
|
||||||
|
Keine eigenen Klassen erfinden!
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
```html
|
||||||
|
<!-- Primary: Orange bg, white text -->
|
||||||
|
<button class="hms-btn hms-btn-primary">Button Text</button>
|
||||||
|
|
||||||
|
<!-- Secondary: Surface bg, muted text, border -->
|
||||||
|
<button class="hms-btn hms-btn-secondary">Button Text</button>
|
||||||
|
|
||||||
|
<!-- Ghost: Transparent, muted text -->
|
||||||
|
<button class="hms-btn hms-btn-ghost">Button Text</button>
|
||||||
|
```
|
||||||
|
- **Padding:** `px-8 py-4` für große Buttons, `px-4 py-2` für kleine, `px-3 py-1.5` für Mini
|
||||||
|
- **Font:** `font-semibold` oder `font-medium`, `text-sm` für Standard
|
||||||
|
- **Hover-Effekte:** Primary → box-shadow glow + translateY(-1px); Secondary → border accent
|
||||||
|
- **Disabled:** `--secondary` bg, `cursor: not-allowed`
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
```html
|
||||||
|
<div class="hms-card p-6">
|
||||||
|
<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>
|
||||||
|
```
|
||||||
|
- **Background:** `var(--panel)` (#1a1a1a)
|
||||||
|
- **Border:** `1px solid var(--border)` (#444444a8)
|
||||||
|
- **Border-Radius:** `4px` (--radius-lg)
|
||||||
|
- **Hover:** Box-shadow wird stärker, Border wird heller
|
||||||
|
|
||||||
|
### Equipment Cards
|
||||||
|
```html
|
||||||
|
<div class="hms-eq-card" @click="..." role="article" tabindex="0">
|
||||||
|
<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" 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 class="hms-btn hms-btn-ghost text-xs px-3 py-1.5" style="color: var(--color-accent)">+ Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badges
|
||||||
|
```html
|
||||||
|
<span class="hms-badge hms-badge-primary">Badge Text</span>
|
||||||
|
<span class="hms-badge hms-badge-gray">Gray Badge</span>
|
||||||
|
<span class="hms-badge hms-badge-success">Success</span>
|
||||||
|
<span class="hms-badge hms-badge-error">Error</span>
|
||||||
|
```
|
||||||
|
- **Border-Radius:** `9999px` (pill shape)
|
||||||
|
- **Primary:** Orange bg (12% alpha), orange text, orange border (20% alpha)
|
||||||
|
|
||||||
|
### Inputs
|
||||||
|
```html
|
||||||
|
<input class="hms-input" type="text" placeholder="..." />
|
||||||
|
<input class="hms-input hms-input-error" /> <!-- mit Fehler -->
|
||||||
|
<select class="hms-input"><option>...</option></select>
|
||||||
|
<textarea class="hms-input" rows="5"></textarea>
|
||||||
|
```
|
||||||
|
- **Background:** `var(--surface)` (#212121)
|
||||||
|
- **Border:** `1px solid var(--border-strong)` (#555555)
|
||||||
|
- **Focus:** Orange border + subtle orange box-shadow
|
||||||
|
- **Error:** Red border + red box-shadow
|
||||||
|
- **Padding:** `var(--space-md) var(--space-lg)`
|
||||||
|
|
||||||
|
### Filter Chips
|
||||||
|
```html
|
||||||
|
<button class="hms-chip" :class="{ active: isActive }">Filter</button>
|
||||||
|
```
|
||||||
|
- **Inactive:** Surface bg, muted text, border
|
||||||
|
- **Active:** Orange bg, white text, orange border
|
||||||
|
|
||||||
|
### Navigation Buttons
|
||||||
|
```html
|
||||||
|
<button class="hms-nav-btn px-4 py-2 rounded-lg text-sm font-medium"
|
||||||
|
:style="isActive ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }"
|
||||||
|
:aria-current="isActive ? 'page' : undefined">
|
||||||
|
Nav Item
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
- **Hover:** Orange text + accent-light bg + Unterstrich-Animation
|
||||||
|
- **Active:** Orange text + accent-light bg
|
||||||
|
|
||||||
|
### Skeleton Loading
|
||||||
|
```html
|
||||||
|
<div class="hms-skeleton h-4 w-3/4"></div>
|
||||||
|
```
|
||||||
|
- Shimmer Animation von `prototype-style.css`
|
||||||
|
|
||||||
|
### Spinner
|
||||||
|
```html
|
||||||
|
<span class="hms-spinner" style="width:18px;height:18px;border-width:2px"></span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Icon Circle (Service Icons)
|
||||||
|
```html
|
||||||
|
<div class="hms-icon-circle mb-4"><span aria-hidden="true">🔊</span></div>
|
||||||
|
```
|
||||||
|
- 48x48px, surface bg, border, centered content
|
||||||
|
|
||||||
|
### Hero
|
||||||
|
```html
|
||||||
|
<section class="hms-hero py-20 sm:py-32">
|
||||||
|
<div class="hms-hero-bg" style="background-image: url('...')"></div>
|
||||||
|
<div class="hms-hero-overlay"></div>
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||||
|
<!-- Content -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
- **hms-hero-bg:** Absolute, background-size cover, opacity 0.35
|
||||||
|
- **hms-hero-overlay:** Linear gradient von rgba(19,19,19,0.6) zu var(--bg)
|
||||||
|
- **Content:** `relative z-10` über Overlay
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Inline Style Pattern
|
||||||
|
|
||||||
|
### REGEL: Inline Styles verwenden IMMER CSS Variablen
|
||||||
|
```html
|
||||||
|
<!-- RICHTIG -->
|
||||||
|
<h1 style="color: var(--text)">Title</h1>
|
||||||
|
<p style="color: var(--text-muted)">Text</p>
|
||||||
|
<div :style="{ background: 'var(--panel)', borderColor: 'var(--border)' }">
|
||||||
|
|
||||||
|
<!-- FALSCH -->
|
||||||
|
<h1 style="color: #ffffff">Title</h1>
|
||||||
|
<p style="color: #d4d4d4">Text</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Häufige Inline Styles aus dem Prototyp
|
||||||
|
- `style="color: var(--text)"` – Haupttext
|
||||||
|
- `style="color: var(--text-muted)"` – Gedimmter Text
|
||||||
|
- `style="color: var(--secondary)"` – Sehr gedimmter Text
|
||||||
|
- `style="color: var(--color-accent)"` – Orange Akzent
|
||||||
|
- `style="background: var(--panel)"` – Panel BG
|
||||||
|
- `style="background: var(--bg)"` – Seiten BG
|
||||||
|
- `style="background: var(--surface)"` – Surface BG
|
||||||
|
- `:style="{ borderColor: 'var(--border)' }"` – Border Color
|
||||||
|
- `:style="{ background: 'var(--color-success-bg)', color: 'var(--color-success)' }"` – Success State
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Komponenten-Struktur
|
||||||
|
|
||||||
|
### Bestehende Komponenten (NICHT neu erfinden)
|
||||||
|
| Komponente | Datei | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| HmsLogo | `components/HmsLogo.vue` | Echtes SVG Logo (H + orange Border) |
|
||||||
|
| SpeakerIcon | `components/SpeakerIcon.vue` | 6 Lautsprecher-Typ SVG Icons |
|
||||||
|
| AppHeader | `components/AppHeader.vue` | Sticky Header, Nav, Cart, Mobile Burger |
|
||||||
|
| AppFooter | `components/AppFooter.vue` | 4-Spalten Footer |
|
||||||
|
| ServiceCard | `components/ServiceCard.vue` | Service Card mit Icon Circle |
|
||||||
|
| EquipmentCard | `components/EquipmentCard.vue` | Equipment Card mit Badge, Image, Add-to-Cart |
|
||||||
|
| LoadingSkeleton | `components/LoadingSkeleton.vue` | Skeleton Loading Cards |
|
||||||
|
| EmptyState | `components/EmptyState.vue` | Empty State mit Icon + Action |
|
||||||
|
| ErrorState | `components/ErrorState.vue` | Error State mit Retry |
|
||||||
|
|
||||||
|
### Neue Komponenten erstellen – Checklist
|
||||||
|
1. CSS Klassen aus `prototype-style.css` verwenden (hms-*)
|
||||||
|
2. Inline Styles mit CSS Variablen
|
||||||
|
3. Tailwind Utilities für Layout/Spacing
|
||||||
|
4. TypeScript mit `lang="ts"`
|
||||||
|
5. `useHead()` für Meta Tags
|
||||||
|
6. ARIA Attributes (role, aria-label, aria-current, etc.)
|
||||||
|
7. Responsive (mobile-first, sm/md/lg breakpoints)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. UX States (MUSS in jeder View implementiert)
|
||||||
|
|
||||||
|
### Loading State
|
||||||
|
```html
|
||||||
|
<loading-skeleton :count="6" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Empty State
|
||||||
|
```html
|
||||||
|
<empty-state icon="🔍" title="Keine Ergebnisse" message="..." action-label="Zurück" @action="..." />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error State
|
||||||
|
```html
|
||||||
|
<error-state title="Fehler" message="..." @retry="..." />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Success State
|
||||||
|
```html
|
||||||
|
<div 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" ...>✓</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text)">Erfolg</h3>
|
||||||
|
<p class="text-sm mb-6" style="color: var(--secondary)">Nachricht</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Submitting State
|
||||||
|
```html
|
||||||
|
<button :disabled="submitting" class="hms-btn hms-btn-primary">
|
||||||
|
<span v-if="submitting" class="hms-spinner" style="width:18px;height:18px;border-width:2px"></span>
|
||||||
|
<span v-else>Senden</span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Accessibility (WCAG 2.1 AA)
|
||||||
|
|
||||||
|
### MUSS-Regeln
|
||||||
|
- **Skip Link:** `<a class="skip-link" href="#main">Zum Inhalt springen</a>` am Seitenanfang
|
||||||
|
- **Semantic HTML:** `<header>`, `<main>`, `<footer>`, `<nav>`, `<section>`, `<article>`
|
||||||
|
- **ARIA:** `role`, `aria-label`, `aria-current="page"`, `aria-expanded`, `aria-invalid`, `aria-describedby`, `role="alert"`, `role="status"`
|
||||||
|
- **Focus Visible:** `*:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }`
|
||||||
|
- **Form Labels:** Jedes Input hat `<label for="...">`, Pflichtfelder mit `<span style="color: var(--color-error)">*</span>`
|
||||||
|
- **Heading Hierarchy:** h1 > h2 > h3, keine Levels überspringen
|
||||||
|
- **Keyboard Navigation:** Alle interaktiven Elemente per Tab erreichbar, `tabindex="0"` auf klickbare Cards
|
||||||
|
- **Alt Text:** Alle Bilder haben `alt="..."`
|
||||||
|
- `aria-hidden="true"` auf dekorativen Icons
|
||||||
|
- `loading="lazy"` auf alle Bilder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Meta Tags & SEO
|
||||||
|
|
||||||
|
### MUSS pro Seite
|
||||||
|
```typescript
|
||||||
|
useHead({
|
||||||
|
title: 'Seite – HMS Licht & Ton',
|
||||||
|
meta: [
|
||||||
|
{ name: 'robots', content: 'noindex, nofollow, noarchive, nosnippet' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON-LD (nur Home)
|
||||||
|
- `LocalBusiness` Schema mit Adresse, Öffnungszeiten, Services
|
||||||
|
|
||||||
|
### OpenGraph
|
||||||
|
- `og:type`, `og:title`, `og:description`, `og:locale`, `og:site_name`, `og:url`
|
||||||
|
|
||||||
|
### REGELN
|
||||||
|
- **noindex** auf ALLEN Seiten (Domain nicht öffentlich indexiert)
|
||||||
|
- **KEINE sitemap.xml**
|
||||||
|
- Inter Font via Google Fonts preconnect
|
||||||
|
- `theme-color: #EC6925` (nur im Head, nicht als Design-Fläche!)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Bilder
|
||||||
|
|
||||||
|
### Hero Background
|
||||||
|
- Unsplash Konzert-Bild: `background-image: url('https://images.unsplash.com/photo-1470229722913-7c0e2dbbafd3?w=1920&q=80')`
|
||||||
|
- **Opacity:** 0.35 (über `.hms-hero-bg`)
|
||||||
|
- **Overlay:** Linear gradient zu `var(--bg)`
|
||||||
|
|
||||||
|
### Referenzen
|
||||||
|
- 9 echte HMS-Fotos: `img/ref1.jpg` bis `img/ref9.jpg`
|
||||||
|
- **Hover:** `group-hover:scale-110` (Zoom-Effekt)
|
||||||
|
- **Gradient Overlay:** `bg-gradient-to-t from-black/50 to-transparent` bei Hover
|
||||||
|
|
||||||
|
### Equipment
|
||||||
|
- Platzhalter `📦` Emoji wenn kein Bild vorhanden
|
||||||
|
- `loading="lazy"` auf allen Bildern
|
||||||
|
|
||||||
|
### About Section
|
||||||
|
- Unsplash Setup-Bild: `https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=800&q=80`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Spacing & Radius
|
||||||
|
|
||||||
|
### Spacing Tokens
|
||||||
|
| Token | Wert |
|
||||||
|
|-------|-------|
|
||||||
|
| `--space-xs` | 0.25rem (4px) |
|
||||||
|
| `--space-sm` | 0.5rem (8px) |
|
||||||
|
| `--space-md` | 1rem (16px) |
|
||||||
|
| `--space-lg` | 1.5rem (24px) |
|
||||||
|
| `--space-xl` | 2rem (32px) |
|
||||||
|
| `--space-2xl` | 3rem (48px) |
|
||||||
|
| `--space-3xl` | 4rem (64px) |
|
||||||
|
| `--space-4xl` | 6rem (96px) |
|
||||||
|
|
||||||
|
### Border Radius (MINIMAL!)
|
||||||
|
| Token | Wert |
|
||||||
|
|-------|-------|
|
||||||
|
| `--radius-sm` | 2px |
|
||||||
|
| `--radius-md` | 3px |
|
||||||
|
| `--radius-lg` | 4px |
|
||||||
|
| `--radius-xl` | 4px |
|
||||||
|
| `--radius-full` | 9999px (nur Badges, Pills) |
|
||||||
|
|
||||||
|
### Shadows
|
||||||
|
| Token | Wert |
|
||||||
|
|-------|-------|
|
||||||
|
| `--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)` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Transitions
|
||||||
|
| Token | Dauer |
|
||||||
|
|-------|-------|
|
||||||
|
| `--transition-fast` | 150ms ease |
|
||||||
|
| `--transition-base` | 250ms ease |
|
||||||
|
| `--transition-slow` | 400ms ease |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Build & Test
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run build # Build (MUSS 0 errors)
|
||||||
|
cd frontend && npx vitest run # Unit Tests (MUSS alle pass)
|
||||||
|
cd frontend && npx playwright test --grep 'Pattern' # E2E Tests
|
||||||
|
curl -s http://localhost:3000/ # Smoke Test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
cd backend && python -m pytest tests/ -v --cov=app # Tests (MUSS alle pass)
|
||||||
|
curl -s http://localhost:8000/api/health # Health Check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
- Server: coolify-01 (46.225.91.159)
|
||||||
|
- Pfad: `/data/hms-licht-ton/`
|
||||||
|
- Branch: `feature/T04-rentman-integration` (oder `main` nach Merge)
|
||||||
|
- Docker Compose: `docker compose up -d`
|
||||||
|
- Domain: `https://hms.media-on.de` (Traefik + Let's Encrypt)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. VERBOTEN
|
||||||
|
- ❌ Eigene CSS Klassen erfinden (nur `hms-*` verwenden)
|
||||||
|
- ❌ Generische `.btn-primary` oder `.nav-link` Klassen
|
||||||
|
- ❌ Hardcoded Hex-Werte in Inline Styles (immer `var(--...)`)
|
||||||
|
- ❌ Fake-Logo (immer echtes SVG aus `assets/logo.svg`)
|
||||||
|
- ❌ Große Orange-Flächen
|
||||||
|
- ❌ Abgerundete Ecken > 4px (außer Badges/Pills = 9999px)
|
||||||
|
- ❌ Client-only rendering für SSR Pages (`/mietkatalog`, `/mietkatalog/**`)
|
||||||
|
- ❌ Fehlende ARIA Attributes
|
||||||
|
- ❌ Fehlende UX States (Loading, Empty, Error, Success)
|
||||||
|
- ❌ Fehlendes `noindex` Meta Tag
|
||||||
|
- ❌ Eigene Templates die nicht dem Prototyp entsprechen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Referenz-Dateien
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `docs/prototype-app.js` | Original Vue 3 SPA Prototyp (761 Zeilen, 19 Komponenten) |
|
||||||
|
| `docs/prototype-index.html` | Original HTML mit Meta Tags, JSON-LD |
|
||||||
|
| `frontend/assets/css/prototype-style.css` | Original CSS mit allen hms-* Klassen (13KB) |
|
||||||
|
| `frontend/assets/css/main.css` | Aktive CSS (enthält prototype-style.css + Tailwind directives) |
|
||||||
|
| `frontend/assets/logo.svg` | Echtes HMS Logo (503 bytes, weißes H + orange Border) |
|
||||||
|
| `frontend/public/logo.svg` | Static copy für Nuxt public dir |
|
||||||
|
| `docs/ui_design.md` | Original UI Design Dokumentation |
|
||||||
|
| `docs/design-rules.md` | DIES DATEI – Design Richtlinien für KI-Agenten |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Schnell-Referenz: Neue Seite erstellen
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<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)">Seiten-Titel</h1>
|
||||||
|
<p class="mb-8" style="color: var(--secondary)">Untertitel</p>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<loading-skeleton v-if="loading" :count="6" />
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<error-state v-else-if="error" title="Fehler" message="..." @retry="loadData" />
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<div v-for="item in items" :key="item.id" class="hms-card p-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text)">{{ item.title }}</h3>
|
||||||
|
<p class="text-sm leading-relaxed" style="color: var(--text-muted)">{{ item.description }}</p>
|
||||||
|
<button class="hms-btn hms-btn-primary mt-4">Aktion</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<empty-state v-if="!loading && !error && items.length === 0"
|
||||||
|
icon="🔍" title="Keine Daten" message="..." action-label="Zurück" @action="..." />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
useHead({
|
||||||
|
title: 'Seite – HMS Licht & Ton',
|
||||||
|
meta: [{ name: 'robots', content: 'noindex, nofollow, noarchive, nosnippet' }],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Dieses Dokument ist die verbindliche Design-Richtlinie für alle Erweiterungen der HMS Licht & Ton Website.*
|
||||||
Vendored
+761
@@ -0,0 +1,761 @@
|
|||||||
|
/* ============================================
|
||||||
|
HMS Licht & Ton – Vue 3 SPA Prototype v4
|
||||||
|
Dark Theme + Subtle Orange + Images + Speaker Grid
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
const { createApp, ref, reactive, computed, onMounted, defineComponent, h } = Vue;
|
||||||
|
|
||||||
|
// ===== Logo Component =====
|
||||||
|
const HmsLogo = defineComponent({
|
||||||
|
name: 'HmsLogo',
|
||||||
|
props: { size: { type: Number, default: 40 } },
|
||||||
|
setup(props) {
|
||||||
|
return () => h('svg', {
|
||||||
|
class: 'hms-logo-svg', viewBox: '0 0 200 200',
|
||||||
|
style: `height:${props.size}px;width:auto`,
|
||||||
|
'aria-label': 'HMS Licht & Ton Logo', role: 'img'
|
||||||
|
}, [
|
||||||
|
h('path', { fill: 'currentColor', d: 'M166.266,172.872h-37.687v-60.718H74.226v60.718H36.539V26.956h37.687v56.335h54.354V26.956h37.687V172.872z' }),
|
||||||
|
h('rect', { x: '23.598', y: '15.343', fill: 'none', stroke: '#EC6925', 'stroke-width': '15.1525', 'stroke-miterlimit': '10', width: '155.612', height: '168.874' })
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Speaker SVG Icon Component =====
|
||||||
|
const SpeakerIcon = defineComponent({
|
||||||
|
name: 'SpeakerIcon',
|
||||||
|
props: { type: { type: String, default: 'linearray' } },
|
||||||
|
setup(props) {
|
||||||
|
const icons = {
|
||||||
|
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>');
|
||||||
|
return { svgStr };
|
||||||
|
},
|
||||||
|
template: '<span class="hms-speaker-icon-wrap" v-html="svgStr"></span>'
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Header =====
|
||||||
|
const AppHeader = defineComponent({
|
||||||
|
name: 'AppHeader',
|
||||||
|
setup() {
|
||||||
|
const mobileMenuOpen = ref(false);
|
||||||
|
const currentRoute = ref(window.location.hash || '#/');
|
||||||
|
const navItems = [
|
||||||
|
{ label: 'Home', route: '#/' },
|
||||||
|
{ label: 'Referenzen', route: '#/referenzen' },
|
||||||
|
{ label: 'Mietkatalog', route: '#/mietkatalog' },
|
||||||
|
{ label: 'Kontakt', route: '#/kontakt' }
|
||||||
|
];
|
||||||
|
function navigate(route) { window.location.hash = route; mobileMenuOpen.value = false; window.scrollTo(0, 0); }
|
||||||
|
function isActive(route) { return currentRoute.value === route || (route !== '#/' && currentRoute.value.startsWith(route)); }
|
||||||
|
window.addEventListener('hashchange', () => { currentRoute.value = window.location.hash || '#/'; mobileMenuOpen.value = false; });
|
||||||
|
return { mobileMenuOpen, currentRoute, navItems, navigate, isActive };
|
||||||
|
},
|
||||||
|
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)' }">
|
||||||
|
<a href="#/" @click.prevent="navigate('#/')" class="flex items-center gap-2" style="color: var(--text)" aria-label="HMS Licht & Ton Startseite">
|
||||||
|
<hms-logo :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>
|
||||||
|
</a>
|
||||||
|
<nav class="hidden md:flex items-center gap-1" role="navigation" aria-label="Hauptnavigation">
|
||||||
|
<button v-for="item in navItems" :key="item.route" @click="navigate(item.route)"
|
||||||
|
class="hms-nav-btn px-4 py-2 rounded-lg text-sm font-medium"
|
||||||
|
:style="isActive(item.route) ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }"
|
||||||
|
:aria-current="isActive(item.route) ? 'page' : undefined">
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
<button @click="navigate('#/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
|
||||||
|
</button>
|
||||||
|
</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">
|
||||||
|
<button v-for="item in navItems" :key="item.route" @click="navigate(item.route)"
|
||||||
|
class="hms-nav-btn block w-full text-left px-4 py-3 rounded-lg text-sm font-medium"
|
||||||
|
:style="isActive(item.route) ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }">
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
<button @click="navigate('#/warenkorb')" class="block w-full text-left px-4 py-3 rounded-lg text-sm font-medium text-white" style="background: var(--color-accent)">🛒 Warenkorb</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</transition>
|
||||||
|
</header>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Footer =====
|
||||||
|
const AppFooter = defineComponent({
|
||||||
|
name: 'AppFooter',
|
||||||
|
setup() {
|
||||||
|
function navigate(route) { window.location.hash = route; window.scrollTo(0, 0); }
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
return { navigate, year };
|
||||||
|
},
|
||||||
|
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)">
|
||||||
|
<hms-logo :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><a href="#/" @click.prevent="navigate('#/')" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">Home</a></li>
|
||||||
|
<li><a href="#/referenzen" @click.prevent="navigate('#/referenzen')" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">Referenzen</a></li>
|
||||||
|
<li><a href="#/mietkatalog" @click.prevent="navigate('#/mietkatalog')" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">Mietkatalog</a></li>
|
||||||
|
<li><a href="#/kontakt" @click.prevent="navigate('#/kontakt')" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">Kontakt</a></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><a href="#/impressum" @click.prevent="navigate('#/kontakt')" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">Impressum</a></li>
|
||||||
|
<li><a href="#/dsgvo" @click.prevent="navigate('#/kontakt')" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">DSGVO</a></li>
|
||||||
|
<li><a href="#/agb" @click.prevent="navigate('#/kontakt')" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">AGB Vermietung</a></li>
|
||||||
|
<li><a href="#/admin" @click.prevent="navigate('#/admin')" style="color: var(--secondary)" class="hover:text-[var(--color-accent)] text-xs">Admin-Login</a></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>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Service Card =====
|
||||||
|
const ServiceCard = defineComponent({
|
||||||
|
name: 'ServiceCard',
|
||||||
|
props: { icon: String, title: String, description: String },
|
||||||
|
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>`
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Equipment Card =====
|
||||||
|
const EquipmentCard = defineComponent({
|
||||||
|
name: 'EquipmentCard',
|
||||||
|
props: { item: Object },
|
||||||
|
emits: ['click', 'add-to-cart'],
|
||||||
|
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>`
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== UX State Components =====
|
||||||
|
const LoadingSkeleton = defineComponent({
|
||||||
|
name: 'LoadingSkeleton',
|
||||||
|
props: { count: { type: Number, default: 6 } },
|
||||||
|
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>`
|
||||||
|
});
|
||||||
|
|
||||||
|
const EmptyState = defineComponent({
|
||||||
|
name: 'EmptyState',
|
||||||
|
props: { icon: { type: String, default: '🔍' }, title: String, message: String, actionLabel: String },
|
||||||
|
emits: ['action'],
|
||||||
|
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>`
|
||||||
|
});
|
||||||
|
|
||||||
|
const ErrorState = defineComponent({
|
||||||
|
name: 'ErrorState',
|
||||||
|
props: { title: { type: String, default: 'Ein Fehler ist aufgetreten' }, message: String },
|
||||||
|
emits: ['retry'],
|
||||||
|
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>`
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== HOME PAGE with Hero Image + Speaker Grid =====
|
||||||
|
const HomePage = defineComponent({
|
||||||
|
name: 'HomePage',
|
||||||
|
emits: ['navigate', 'add-to-cart'],
|
||||||
|
setup(_, { emit }) {
|
||||||
|
function navigate(route) { window.location.hash = route; window.scrollTo(0, 0); }
|
||||||
|
const services = [
|
||||||
|
{ icon: '🔊', title: 'Vermietung', description: 'Lautsprecher, Mischpulte, Lichtanlagen, Rigging und Komplett-Setups aus unserem 1.000+ Geräte umfassenden Mietlager in Ellzee. Geprüftes Equipment, sofort einsatzbereit.' },
|
||||||
|
{ icon: '🛒', title: 'Verkauf', description: 'Neu- und Gebrauchtgeräte führender Hersteller mit Beratung, Inbetriebnahme und Service. Wir liefern nicht nur – wir konfigurieren Ihre Anlage einsatzfertig.' },
|
||||||
|
{ icon: '👷', title: 'Personal', description: 'Qualifizierte Tontechniker, Lichttechniker und Rigger für Aufbau, Betrieb und Abbau. Mit TÜV-geprüfter Ausbildung und umfangreicher Praxiserfahrung.' },
|
||||||
|
{ icon: '🚚', title: 'Transport', description: 'Eigene Transportfahrzeuge für sichere An- und Ablieferung. Inklusive Verladekonzept, Positionierung und Rückholung – kundengerecht terminiert.' },
|
||||||
|
{ icon: '📦', title: 'Lagerung', description: 'Klimatisierte und trockene Lagerung für Equipment und Veranstaltungsbestände. Mit Bestandsmanagement und regelmäßiger Funktionsprüfung.' },
|
||||||
|
{ icon: '🔧', title: 'Werkstatt', description: 'Herstellerübergreifende Reparatur und Wartung in unserer eigenen Werkstatt. Regelmäßige DGUV-Prüfungen und Kalibrierung für Ihren sicheren Betrieb.' },
|
||||||
|
{ icon: '⚡', title: 'Installation', description: 'Festinstallationen in Schaufenstern, Lokalen und Veranstaltungsstätten. Von der Planung über die Elektroinstallation bis zur Abnahme – alles aus einer Hand.' },
|
||||||
|
{ icon: '📅', title: 'Booking', description: 'Vermittlung von Künstlern und Technik-Personal für Ihre Veranstaltung. Mit unserem branchenweiten Netzwerk finden wir die passenden Professionals für Ihr Event.' }
|
||||||
|
];
|
||||||
|
const speakers = [
|
||||||
|
{ type: 'linearray', name: 'L-Acoustics K2', category: 'Line Array' },
|
||||||
|
{ type: 'subwoofer', name: 'L-Acoustics KS28', category: 'Subwoofer' },
|
||||||
|
{ type: 'pointsource', name: 'd&b T10', category: 'Point Source' },
|
||||||
|
{ type: 'column', name: 'd&b KSL8', category: 'Column Array' },
|
||||||
|
{ type: 'monitor', name: 'd&b M4', category: 'Monitor' },
|
||||||
|
{ type: 'movinghead', name: 'Robe Pointe', category: 'Moving Head' }
|
||||||
|
];
|
||||||
|
return { services, speakers, navigate };
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<!-- Hero with background image -->
|
||||||
|
<section class="hms-hero py-20 sm:py-32" aria-labelledby="hero-title">
|
||||||
|
<div class="hms-hero-bg" style="background-image: url('https://images.unsplash.com/photo-1470229722913-7c0e2dbbafd3?w=1920&q=80')"></div>
|
||||||
|
<div class="hms-hero-overlay"></div>
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||||
|
<div class="max-w-2xl">
|
||||||
|
<span class="hms-badge hms-badge-primary mb-4">Seit 2003 · Über 20 Jahre Veranstaltungstechnik</span>
|
||||||
|
<h1 id="hero-title" class="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight mb-6" style="color: var(--text)">
|
||||||
|
Professionelle Technik<br><span style="color: var(--color-accent)">für Ihre Veranstaltung</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg sm:text-xl mb-8 max-w-xl" style="color: var(--text-muted)">
|
||||||
|
Von der Firmenevent-Beschallung bis zur Open-Air-Großproduktion: HMS Licht & Ton liefert Tontechnik, Lichttechnik, Rigging und Komplettlösungen aus Leipheim und Ellzee.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4">
|
||||||
|
<button @click="navigate('#/mietkatalog')" class="hms-btn hms-btn-primary text-base px-8 py-4">
|
||||||
|
Mietkatalog öffnen
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
|
||||||
|
</button>
|
||||||
|
<button @click="navigate('#/kontakt')" class="hms-btn hms-btn-secondary text-base px-8 py-4">Beratung anfragen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Speaker Grid -->
|
||||||
|
<section class="py-12 sm:py-16" :style="{ background: 'var(--bg)' }" aria-labelledby="speaker-title">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="mb-8">
|
||||||
|
<span class="text-sm font-semibold uppercase tracking-wider" style="color: var(--color-accent)">Equipment-Highlights</span>
|
||||||
|
<h2 id="speaker-title" class="text-2xl sm:text-3xl font-bold mt-1" style="color: var(--text)">Lautsprecher & Strahler aus unserem Mietlager</h2>
|
||||||
|
</div>
|
||||||
|
<div class="hms-speaker-grid">
|
||||||
|
<div v-for="sp in speakers" :key="sp.name" class="hms-speaker-item" @click="navigate('#/mietkatalog')" role="button" tabindex="0" @keydown.enter="navigate('#/mietkatalog')">
|
||||||
|
<speaker-icon :type="sp.type" />
|
||||||
|
<div class="hms-speaker-name">{{ sp.name }}</div>
|
||||||
|
<div class="hms-speaker-type">{{ sp.category }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Über uns -->
|
||||||
|
<section class="py-16 sm:py-20" :style="{ background: 'var(--panel)' }" aria-labelledby="about-title">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="grid lg:grid-cols-2 gap-12 items-center">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-semibold uppercase tracking-wider mb-2" style="color: var(--color-accent)">Unternehmen</span>
|
||||||
|
<h2 id="about-title" class="text-3xl sm:text-4xl font-bold mb-6" style="color: var(--text)">Ihr Technik-Partner mit Erfahrung</h2>
|
||||||
|
<p class="leading-relaxed mb-4" style="color: var(--text-muted)">Die <strong style="color: var(--text)">Hammerschmidt u. Mössle GbR</strong> ist seit 2003 Ihr zuverlässiger Partner für Veranstaltungstechnik in der Region Leipheim / Ellzee und im gesamten süddeutschen Raum.</p>
|
||||||
|
<p class="leading-relaxed mb-4" style="color: var(--text-muted)">Mit einem Mietlager von über 1.000 Geräten, einer eigenen Werkstatt und erfahrenem Personal realisieren wir Veranstaltungen jeder Größenordnung.</p>
|
||||||
|
<div class="grid grid-cols-3 gap-4 mt-8">
|
||||||
|
<div class="text-center"><div class="text-3xl font-bold" style="color: var(--color-accent)">20+</div><div class="text-sm" style="color: var(--secondary)">Jahre Erfahrung</div></div>
|
||||||
|
<div class="text-center"><div class="text-3xl font-bold" style="color: var(--color-accent)">1.000+</div><div class="text-sm" style="color: var(--secondary)">Geräte im Lager</div></div>
|
||||||
|
<div class="text-center"><div class="text-3xl font-bold" style="color: var(--color-accent)">2</div><div class="text-sm" style="color: var(--secondary)">Standorte</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="aspect-[4/3] rounded-lg overflow-hidden shadow-xl">
|
||||||
|
<img src="https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=800&q=80" alt="Veranstaltungstechnik Setup" loading="lazy" class="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Leistungen -->
|
||||||
|
<section class="py-16 sm:py-20" :style="{ background: 'var(--bg)' }" aria-labelledby="services-title">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<span class="text-sm font-semibold uppercase tracking-wider mb-2" style="color: var(--color-accent)">Leistungen</span>
|
||||||
|
<h2 id="services-title" class="text-3xl sm:text-4xl font-bold" style="color: var(--text)">Von der Vermietung bis zur Komplettbetreuung</h2>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<service-card v-for="s in services" :key="s.title" :icon="s.icon" :title="s.title" :description="s.description" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<section class="py-16 sm:py-20" :style="{ background: 'var(--panel)' }" aria-labelledby="rental-title">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="rounded-lg p-8 sm:p-12 lg:p-16 text-center" style="background: var(--bg); border: 1px solid var(--border)">
|
||||||
|
<h2 id="rental-title" class="text-3xl sm:text-4xl font-bold mb-4" style="color: var(--text)">Online-Mietkatalog</h2>
|
||||||
|
<p class="max-w-2xl mx-auto mb-8" style="color: var(--text-muted)">Durchsuchen Sie unser Equipment-Sortiment und stellen Sie Ihre Mietanfrage direkt online zusammen.</p>
|
||||||
|
<button @click="navigate('#/mietkatalog')" class="hms-btn hms-btn-primary text-base px-8 py-4">
|
||||||
|
Katalog durchsuchen
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>`
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== REFERENZEN PAGE with real HMS images =====
|
||||||
|
const ReferenzenPage = defineComponent({
|
||||||
|
name: 'ReferenzenPage',
|
||||||
|
setup() {
|
||||||
|
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); }
|
||||||
|
return { loading, error, filter, categories, filteredImages, retry };
|
||||||
|
},
|
||||||
|
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>
|
||||||
|
<error-state 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>
|
||||||
|
<empty-state 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>`
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== KONTAKT PAGE =====
|
||||||
|
const KontaktPage = defineComponent({
|
||||||
|
name: 'KontaktPage',
|
||||||
|
setup() {
|
||||||
|
const form = reactive({ name: '', email: '', phone: '', subject: '', message: '', privacy: false });
|
||||||
|
const errors = reactive({});
|
||||||
|
const submitted = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
function validate() {
|
||||||
|
const e = {};
|
||||||
|
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; }
|
||||||
|
return { form, errors, submitted, submitting, validate, submit, resetForm };
|
||||||
|
},
|
||||||
|
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 <a href="#/dsgvo" style="color: var(--color-accent)">Datenschutzerklärung</a> 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>`
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== MIETKATALOG PAGE =====
|
||||||
|
const MietkatalogPage = defineComponent({
|
||||||
|
name: 'MietkatalogPage',
|
||||||
|
emits: ['navigate', 'add-to-cart'],
|
||||||
|
setup(_, { emit }) {
|
||||||
|
const loading = ref(true); const error = ref(false);
|
||||||
|
const searchQuery = ref(''); const activeCategory = ref('alle'); const sortBy = ref('name');
|
||||||
|
const categories = ['alle', 'Tontechnik', 'Lichttechnik', 'Rigging', 'Video', 'Strom', 'Zubehör'];
|
||||||
|
const allEquipment = [
|
||||||
|
{ id: 1, code: 'LT-001', name: 'L-Acoustics K2', category: 'Tontechnik', description: 'High-Performance Line-Array Element mit variabler Krümmung für große Open-Air-Produktionen und Hallenbeschallung', brand: 'L-Acoustics', specs: { weight: '56 kg', power: '1440 W', freq_range: '35 Hz - 20 kHz' }, image: '' },
|
||||||
|
{ id: 2, code: 'LT-002', name: 'd&b audiotechnik KSL8', category: 'Tontechnik', description: 'Bi-amplified Column Array Loudspeaker, 80° horizontal, für mittlere bis große Events', brand: 'd&b audiotechnik', specs: { weight: '32 kg', power: '1200 W', freq_range: '55 Hz - 18 kHz' }, image: '' },
|
||||||
|
{ id: 3, code: 'LT-003', name: 'Shure SM58', category: 'Tontechnik', description: 'Dynamisches Gesangsmikrofon, Kardioid-Richtcharakteristik, Industrie-Standard seit Jahrzehnten', brand: 'Shure', specs: { weight: '330 g', type: 'dynamisch', polar: 'Kardioid' }, image: '' },
|
||||||
|
{ id: 4, code: 'LT-004', name: 'Allen & Heath dLive S5000', category: 'Tontechnik', description: 'Digitales Mischpult mit 96 Input Fadern, 64 Mix Buses, Dante-kompatibel für große Live-Produktionen', brand: 'Allen & Heath', specs: { channels: '96', buses: '64', fx: '16 Effekt-Engines' }, image: '' },
|
||||||
|
{ id: 5, code: 'LL-001', name: 'Robe Pointe', category: 'Lichttechnik', description: '230W Moving Beam mit 5°-20° Zoom, 16-bit Dimming, vielseitig für Beam- und Wash-Anwendungen', brand: 'Robe', specs: { power: '230 W', source: 'MSD 230', zoom: '5°-20°' }, image: '' },
|
||||||
|
{ id: 6, code: 'LL-002', name: 'Mac Aura PXL', category: 'Lichttechnik', description: 'RGBW Wash-Light mit 18 pixel-mappable Zellen, Zoom 4°-60°, Art-Net steuerbar', brand: 'Martin', specs: { power: '700 W', cells: '18', zoom: '4°-60°' }, image: '' },
|
||||||
|
{ id: 7, code: 'LL-003', name: 'Showtec Phantom 140', category: 'Lichttechnik', description: '140W LED Spot Moving Head, 9 rotierende + 9 feste Gobos, 8-fach Prisma', brand: 'Showtec', specs: { power: '140 W', gobos: '9 rotierend + 9 fest', zoom: '12°-18°' }, image: '' },
|
||||||
|
{ id: 8, code: 'LL-004', name: 'Astera Titan Tube', category: 'Lichttechnik', description: 'Pixel Tube 1m, batteriebetrieben mit 20 Std. Laufzeit, DMX/CRMX-Steuerung, RGBW', brand: 'Astera', specs: { length: '1015 mm', battery: '20 Std', cells: '16 Pixel' }, image: '' },
|
||||||
|
{ id: 9, code: 'RG-001', name: 'Tomcat Truss 290', category: 'Rigging', description: 'Aluminium Traverse 4-Punkt, 290mm Profil, 2m Länge, belastbar bis 450kg freitragend', brand: 'Tomcat', specs: { profile: '290x290 mm', length: '2 m', load: '450 kg' }, image: '' },
|
||||||
|
{ id: 10, code: 'RG-002', name: 'Chain Motor 1T', category: 'Rigging', description: 'Bühnen-Motor 1000kg, D8+ Plus geprüft, inklusive Steuerkabel und Lasthaken', brand: 'Verlinde', specs: { capacity: '1000 kg', speed: '4-8 m/min', control: 'Direct Control' }, image: '' },
|
||||||
|
{ id: 11, code: 'VD-001', name: 'LED Video Wall P3.9', category: 'Video', description: 'Indoor LED Panel P3.9, 500x500mm, 3840Hz Refresh Rate, 1500 nits für Bühnenhintergründe', brand: 'Absen', specs: { pixel_pitch: '3.9 mm', size: '500x500 mm', refresh: '3840 Hz' }, image: '' },
|
||||||
|
{ id: 12, code: 'VD-002', name: 'Blackmagic ATEM Mini Extreme', category: 'Video', description: '8-Input HDMI Switcher mit ISO Recording, Multiview und Direct Streaming', brand: 'Blackmagic', specs: { inputs: '8x HDMI', outputs: '2x HDMI', recording: 'ISO Recording' }, image: '' },
|
||||||
|
{ id: 13, code: 'ST-001', name: 'WAGO 24V 40A Netzteilkasten', category: 'Strom', description: '24V DC 40A Netzteil in Flightcase, 4x Schuko Ausgang, für LED- und Steuerungstechnik', brand: 'WAGO', specs: { output: '24V DC 40A', inputs: '4x Schuko', protection: 'IP54' }, image: '' },
|
||||||
|
{ id: 14, code: 'ST-002', name: 'Duraplex 32A Stromverteiler', category: 'Strom', description: '32A 5-polig auf 3x 16A + 3x Schuko, mit FI-Schutzschalter, professionelle Bühnenstromversorgung', brand: 'Duraplex', specs: { input: '32A 5-pol CEE', outputs: '3x 16A + 3x Schuko', protection: 'FI/A' }, image: '' },
|
||||||
|
{ id: 15, code: 'ZB-001', name: 'Flightcase 19" 12HE', category: 'Zubehör', description: '19" Rack Flightcase 12HE, Front- und Rücktür, 4x 100mm Rollen, Birkenholz mit Alu-Kanten', brand: 'Thon', specs: { rack_units: '12 HE', depth: '600 mm', wheels: '4x 100mm' }, image: '' },
|
||||||
|
{ id: 16, code: 'ZB-002', name: 'XLR Kabel 20m', category: 'Zubehör', description: 'XLR3 male/female Mikrofonkabel, 20m, Neutrik Stecker, doppelt geschirmt', brand: 'Tasker', specs: { length: '20 m', connectors: 'XLR3 M/F', shielding: 'double' }, image: '' },
|
||||||
|
{ id: 17, code: 'ZB-003', name: 'DJ-Table Pro', category: 'Zubehör', description: 'Mobile DJ-Tisch mit Laptop-Ständer und integrierter LED-Beleuchtung, transportiert im Flightcase', brand: 'Stage Traps', specs: { width: '120 cm', height: '95 cm', features: 'LED, Laptop-Ständer' }, image: '' },
|
||||||
|
{ id: 18, code: 'LT-005', name: 'Sennheiser EW-DX 835', category: 'Tontechnik', description: 'Digitales Funkmikrofon-Set, Handheld + Empfänger, 100m Reichweite, 12 Std. Akkulaufzeit', brand: 'Sennheiser', specs: { type: 'Digital Wireless', range: '100 m', battery: '12 Std' }, image: '' }
|
||||||
|
];
|
||||||
|
const filteredEquipment = computed(() => {
|
||||||
|
let items = allEquipment;
|
||||||
|
if (activeCategory.value !== 'alle') items = items.filter(i => i.category === activeCategory.value);
|
||||||
|
if (searchQuery.value.trim()) { const q = searchQuery.value.toLowerCase(); items = items.filter(i => i.name.toLowerCase().includes(q) || i.code.toLowerCase().includes(q) || i.brand.toLowerCase().includes(q)); }
|
||||||
|
if (sortBy.value === 'name') items = [...items].sort((a,b) => a.name.localeCompare(b.name));
|
||||||
|
else if (sortBy.value === 'brand') items = [...items].sort((a,b) => a.brand.localeCompare(b.brand));
|
||||||
|
else if (sortBy.value === 'code') items = [...items].sort((a,b) => a.code.localeCompare(b.code));
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
onMounted(() => { setTimeout(() => { loading.value = false; }, 1000); });
|
||||||
|
function navigate(route) { window.location.hash = route; window.scrollTo(0, 0); }
|
||||||
|
function navigateToDetail(item) { window.location.hash = '#/mietkatalog/' + item.id; window.scrollTo(0, 0); }
|
||||||
|
function retry() { loading.value = true; error.value = false; setTimeout(() => { loading.value = false; }, 800); }
|
||||||
|
return { loading, error, searchQuery, activeCategory, sortBy, categories, filteredEquipment, navigate, navigateToDetail, retry };
|
||||||
|
},
|
||||||
|
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" /></div>
|
||||||
|
<select v-model="sortBy" class="hms-input lg:w-48" aria-label="Sortieren nach"><option value="name">Sortieren: Name (A-Z)</option><option value="brand">Sortieren: Marke (A-Z)</option><option value="code">Sortieren: Artikelnummer</option></select>
|
||||||
|
</div>
|
||||||
|
<div 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" :class="['hms-chip', activeCategory === cat ? 'active' : '']" role="tab" :aria-selected="activeCategory === cat">{{ cat === 'alle' ? 'Alle Kategorien' : cat }}</button></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!loading && !error" class="text-sm mb-4" style="color: var(--secondary)" aria-live="polite">{{ filteredEquipment.length }} {{ filteredEquipment.length === 1 ? 'Gerät' : 'Geräte' }} gefunden</div>
|
||||||
|
<loading-skeleton v-if="loading" :count="6" />
|
||||||
|
<error-state v-else-if="error" title="Katalog nicht erreichbar" message="Der Equipment-Katalog ist aktuell nicht verfügbar." @retry="retry" />
|
||||||
|
<empty-state v-else-if="filteredEquipment.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'" />
|
||||||
|
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"><equipment-card v-for="item in filteredEquipment" :key="item.id" :item="item" @click="navigateToDetail" @add-to-cart="$emit('add-to-cart', $event)" /></div>
|
||||||
|
</div>`
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== EQUIPMENT DETAIL PAGE =====
|
||||||
|
const EquipmentDetailPage = defineComponent({
|
||||||
|
name: 'EquipmentDetailPage',
|
||||||
|
props: { id: [String, Number], cart: Array },
|
||||||
|
emits: ['add-to-cart', 'navigate'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const loading = ref(true); const error = ref(false); const quantity = ref(1);
|
||||||
|
const rentalStart = ref(''); const rentalEnd = ref(''); const item = ref(null);
|
||||||
|
const equipmentData = [
|
||||||
|
{ id: 1, code: 'LT-001', name: 'L-Acoustics K2', category: 'Tontechnik', description: 'Das L-Acoustics K2 ist das Flaggschiff der Line-Array-Serie und eignet sich für große Open-Air-Veranstaltungen und Hallenbeschallung.', brand: 'L-Acoustics', specs: { weight: '56 kg', power: '1440 W', freq_range: '35 Hz - 20 kHz', max_spl: '142 dB', coverage: '10°-110° variable' }, image: '', related: [2, 4, 3] },
|
||||||
|
{ id: 2, code: 'LT-002', name: 'd&b audiotechnik KSL8', category: 'Tontechnik', description: 'Bi-amplified Column Array Loudspeaker mit 80° horizontaler Abstrahlung.', brand: 'd&b audiotechnik', specs: { weight: '32 kg', power: '1200 W', freq_range: '55 Hz - 18 kHz', max_spl: '138 dB', coverage: '80° horizontal' }, image: '', related: [1, 4, 18] },
|
||||||
|
{ id: 3, code: 'LT-003', name: 'Shure SM58', category: 'Tontechnik', description: 'Das weltweit meistverkaufte dynamische Gesangsmikrofon.', brand: 'Shure', specs: { weight: '330 g', type: 'dynamisch', polar: 'Kardioid', freq_range: '50 Hz - 15 kHz', connector: 'XLR3' }, image: '', related: [4, 18, 1] },
|
||||||
|
{ id: 4, code: 'LT-004', name: 'Allen & Heath dLive S5000', category: 'Tontechnik', description: 'Professionelles digitales Mischpult mit 96 Input Fadern und 64 Mix Buses.', brand: 'Allen & Heath', specs: { channels: '96', buses: '64', fx: '16 Effekt-Engines', screens: '2x 15" Touch', i_o: '128x128 Dante' }, image: '', related: [1, 2, 3] },
|
||||||
|
{ id: 5, code: 'LL-001', name: 'Robe Pointe', category: 'Lichttechnik', description: '230W Moving Beam mit 5°-20° Zoom und 16-bit Dimming.', brand: 'Robe', specs: { power: '230 W', source: 'MSD 230', zoom: '5°-20°', dimming: '16-bit', pan: '540°', tilt: '270°' }, image: '', related: [6, 7, 8] },
|
||||||
|
{ id: 6, code: 'LL-002', name: 'Mac Aura PXL', category: 'Lichttechnik', description: 'RGBW Wash-Light mit 18 einzelnen pixel-mappable Zellen.', brand: 'Martin', specs: { power: '700 W', cells: '18', zoom: '4°-60°', control: 'DMX 512, Art-Net', color: 'RGBW' }, image: '', related: [5, 7, 8] },
|
||||||
|
{ id: 7, code: 'LL-003', name: 'Showtec Phantom 140', category: 'Lichttechnik', description: '140W LED Spot Moving Head mit 9 rotierenden und 9 festen Gobos.', brand: 'Showtec', specs: { power: '140 W', gobos: '9 rotierend + 9 fest', zoom: '12°-18°', color: '7+1 Farbrad', prism: '8-fach rotierend' }, image: '', related: [5, 6, 8] },
|
||||||
|
{ id: 8, code: 'LL-004', name: 'Astera Titan Tube', category: 'Lichttechnik', description: 'Pixel Tube 1m Länge, batteriebetrieben mit 20 Std. Laufzeit.', brand: 'Astera', specs: { length: '1015 mm', battery: '20 Std', cells: '16 Pixel', control: 'DMX, CRMX, App', color: 'RGBW' }, image: '', related: [5, 6, 7] },
|
||||||
|
{ id: 9, code: 'RG-001', name: 'Tomcat Truss 290', category: 'Rigging', description: 'Aluminium Traverse 4-Punkt, 290mm Profil, 2m Länge.', brand: 'Tomcat', specs: { profile: '290x290 mm', length: '2 m', load: '450 kg', material: 'Aluminium EN AW-6082' }, image: '', related: [10, 14, 15] },
|
||||||
|
{ id: 10, code: 'RG-002', name: 'Chain Motor 1T', category: 'Rigging', description: 'Bühnen-Motor 1000kg Tragkraft, D8+ Plus geprüft.', brand: 'Verlinde', specs: { capacity: '1000 kg', speed: '4-8 m/min', control: 'Direct Control', certification: 'D8+ Plus' }, image: '', related: [9, 14, 13] },
|
||||||
|
{ id: 11, code: 'VD-001', name: 'LED Video Wall P3.9', category: 'Video', description: 'Indoor LED Panel P3.9, 500x500mm, 3840Hz Refresh Rate.', brand: 'Absen', specs: { pixel_pitch: '3.9 mm', size: '500x500 mm', refresh: '3840 Hz', brightness: '1500 nits', ip: 'IP30 Indoor' }, image: '', related: [12, 5, 6] },
|
||||||
|
{ id: 12, code: 'VD-002', name: 'Blackmagic ATEM Mini Extreme', category: 'Video', description: '8-Input HDMI Switcher mit ISO Recording und Multiview.', brand: 'Blackmagic', specs: { inputs: '8x HDMI', outputs: '2x HDMI', recording: 'ISO Recording', streaming: 'Direct Streaming', audio: '2x 3.5mm' }, image: '', related: [11, 5, 7] },
|
||||||
|
{ id: 13, code: 'ST-001', name: 'WAGO 24V 40A Netzteilkasten', category: 'Strom', description: '24V DC 40A Netzteil in Flightcase mit 4x Schuko Ausgang.', brand: 'WAGO', specs: { output: '24V DC 40A', inputs: '4x Schuko', protection: 'IP54', cooling: 'Forced Air' }, image: '', related: [14, 10, 9] },
|
||||||
|
{ id: 14, code: 'ST-002', name: 'Duraplex 32A Stromverteiler', category: 'Strom', description: '32A 5-polig auf 3x 16A + 3x Schuko mit FI-Schutzschalter.', brand: 'Duraplex', specs: { input: '32A 5-pol CEE', outputs: '3x 16A + 3x Schuko', protection: 'FI/A', cable: 'H07BQ-F 5G6' }, image: '', related: [13, 10, 9] },
|
||||||
|
{ id: 15, code: 'ZB-001', name: 'Flightcase 19" 12HE', category: 'Zubehör', description: '19" Rack Flightcase 12HE mit Front- und Rücktür.', brand: 'Thon', specs: { rack_units: '12 HE', depth: '600 mm', wheels: '4x 100mm', material: 'Birkenholz + Alu-Kanten' }, image: '', related: [16, 4, 15] },
|
||||||
|
{ id: 16, code: 'ZB-002', name: 'XLR Kabel 20m', category: 'Zubehör', description: 'XLR3 male/female Mikrofonkabel, 20m, Neutrik Stecker.', brand: 'Tasker', specs: { length: '20 m', connectors: 'XLR3 M/F', shielding: 'double', cable: 'Tasker C118' }, image: '', related: [3, 18, 4] },
|
||||||
|
{ id: 17, code: 'ZB-003', name: 'DJ-Table Pro', category: 'Zubehör', description: 'Mobile DJ-Tisch mit Laptop-Ständer und LED-Beleuchtung.', brand: 'Stage Traps', specs: { width: '120 cm', height: '95 cm', features: 'LED, Laptop-Ständer', weight: '22 kg' }, image: '', related: [12, 5, 15] },
|
||||||
|
{ id: 18, code: 'LT-005', name: 'Sennheiser EW-DX 835', category: 'Tontechnik', description: 'Digitales Funkmikrofon-Set mit Handheld und Empfänger.', brand: 'Sennheiser', specs: { type: 'Digital Wireless', range: '100 m', battery: '12 Std', freq_range: '563-608 MHz', channels: 'gleichzeitig 95' }, image: '', related: [3, 4, 16] }
|
||||||
|
];
|
||||||
|
const relatedItems = computed(() => { if (!item.value || !item.value.related) return []; return item.value.related.map(id => equipmentData.find(e => e.id === id)).filter(Boolean); });
|
||||||
|
onMounted(() => { setTimeout(() => { const found = equipmentData.find(e => e.id === Number(props.id)); if (found) { item.value = found; } else { error.value = true; } loading.value = false; }, 800); });
|
||||||
|
function navigate(route) { window.location.hash = route; window.scrollTo(0, 0); }
|
||||||
|
function addToCart() { emit('add-to-cart', { ...item.value, quantity: quantity.value, rentalStart: rentalStart.value, rentalEnd: rentalEnd.value }); navigate('#/warenkorb'); }
|
||||||
|
function addToCartSimple(e) { emit('add-to-cart', e); }
|
||||||
|
return { loading, error, item, quantity, rentalStart, rentalEnd, relatedItems, navigate, addToCart, addToCartSimple };
|
||||||
|
},
|
||||||
|
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"><a href="#/mietkatalog" @click.prevent="navigate('#/mietkatalog')" style="color: var(--secondary)">← Zurück zum Katalog</a></nav>
|
||||||
|
<div v-if="loading" 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>
|
||||||
|
<error-state 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" :src="item.image" :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 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.code }}</div>
|
||||||
|
<h1 class="text-2xl sm:text-3xl font-bold mb-2" style="color: var(--text)">{{ item.name }}</h1>
|
||||||
|
<div class="text-sm mb-4" style="color: var(--text-muted)">Marke: <span class="font-medium" style="color: var(--text)">{{ item.brand }}</span></div>
|
||||||
|
<p class="leading-relaxed mb-6" style="color: var(--text-muted)">{{ item.description }}</p>
|
||||||
|
<div 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.specs" :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>
|
||||||
|
<section v-if="relatedItems.length" class="mt-16" aria-labelledby="related-title"><h2 id="related-title" class="text-xl font-semibold mb-6" style="color: var(--text)">Ähnliche Geräte</h2><div class="grid grid-cols-1 sm:grid-cols-3 gap-6"><equipment-card v-for="ri in relatedItems" :key="ri.id" :item="ri" @click="navigate('#/mietkatalog/' + ri.id)" @add-to-cart="addToCartSimple" /></div></section>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== WARENKORB PAGE =====
|
||||||
|
const WarenkorbPage = defineComponent({
|
||||||
|
name: 'WarenkorbPage',
|
||||||
|
props: { cart: Array },
|
||||||
|
emits: ['remove-item', 'update-quantity', 'navigate', 'clear-cart'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const submitting = ref(false); const submitted = ref(false);
|
||||||
|
const form = reactive({ name: '', email: '', phone: '', event_date: '', event_location: '', message: '' });
|
||||||
|
const errors = reactive({});
|
||||||
|
function removeItem(index) { emit('remove-item', index); }
|
||||||
|
function updateQty(index, delta) { emit('update-quantity', { index, delta }); }
|
||||||
|
function navigate(route) { window.location.hash = route; window.scrollTo(0, 0); }
|
||||||
|
function validate() {
|
||||||
|
const e = {};
|
||||||
|
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; emit('clear-cart'); }, 1800); }
|
||||||
|
function resetForm() { Object.assign(form, { name: '', email: '', phone: '', event_date: '', event_location: '', message: '' }); submitted.value = false; }
|
||||||
|
return { submitting, submitted, form, errors, removeItem, updateQty, navigate, validate, submitRequest, resetForm };
|
||||||
|
},
|
||||||
|
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>
|
||||||
|
<empty-state v-else-if="cart.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 cart" :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.code }} · {{ item.brand }}</div></div>
|
||||||
|
<button @click="removeItem(index)" 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="updateQty(index, -1)" 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="updateQty(index, 1)" 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 v-if="item.rentalStart" class="text-xs" style="color: var(--secondary)">📅 {{ item.rentalStart }} <span v-if="item.rentalEnd">bis {{ item.rentalEnd }}</span></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="$emit('clear-cart')" 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)">{{ cart.reduce((s,i)=>s+i.quantity,0) }}</span></div><div class="flex justify-between"><span>Positionen:</span><span class="font-medium" style="color: var(--text)">{{ cart.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>`
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== ADMIN PAGE =====
|
||||||
|
const AdminPage = defineComponent({
|
||||||
|
name: 'AdminPage',
|
||||||
|
setup() {
|
||||||
|
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); }
|
||||||
|
return { username, password, error, loading, loggedIn, login };
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="max-w-md mx-auto px-4 py-16"><div class="hms-card p-8"><div class="text-center mb-6"><hms-logo :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>`
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 404 =====
|
||||||
|
const NotFoundPage = defineComponent({
|
||||||
|
name: 'NotFoundPage',
|
||||||
|
template: `<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><a href="#/" class="hms-btn hms-btn-primary">Zur Startseite</a></div>`
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== MAIN APP =====
|
||||||
|
const App = defineComponent({
|
||||||
|
name: 'App',
|
||||||
|
setup() {
|
||||||
|
const currentRoute = ref(window.location.hash || '#/');
|
||||||
|
const cart = reactive([]);
|
||||||
|
function addToCart(item) { const existing = cart.find(i => i.id === item.id); if (existing) { existing.quantity += item.quantity || 1; } else { cart.push({ ...item, quantity: item.quantity || 1 }); } }
|
||||||
|
function removeItem(index) { cart.splice(index, 1); }
|
||||||
|
function updateQuantity({ index, delta }) { const item = cart[index]; if (item) { item.quantity = Math.max(1, item.quantity + delta); } }
|
||||||
|
function clearCart() { cart.splice(0, cart.length); }
|
||||||
|
const routeParts = computed(() => { const hash = currentRoute.value.replace(/^#/, '') || '/'; return hash.split('/').filter(Boolean); });
|
||||||
|
const currentView = computed(() => { const parts = routeParts.value; if (parts.length === 0) return 'home'; if (parts[0] === 'referenzen') return 'referenzen'; if (parts[0] === 'kontakt') return 'kontakt'; if (parts[0] === 'mietkatalog') return parts.length > 1 ? 'equipment-detail' : 'mietkatalog'; if (parts[0] === 'warenkorb') return 'warenkorb'; if (parts[0] === 'admin') return 'admin'; return '404'; });
|
||||||
|
const detailId = computed(() => { const parts = routeParts.value; if (parts[0] === 'mietkatalog' && parts.length > 1) return parts[1]; return null; });
|
||||||
|
window.addEventListener('hashchange', () => { currentRoute.value = window.location.hash || '#/'; window.scrollTo(0, 0); });
|
||||||
|
return { currentRoute, currentView, detailId, cart, addToCart, removeItem, updateQuantity, clearCart };
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="min-h-screen flex flex-col">
|
||||||
|
<a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a>
|
||||||
|
<app-header />
|
||||||
|
<main id="main-content" class="flex-1" role="main">
|
||||||
|
<transition name="fade" mode="out-in">
|
||||||
|
<home-page v-if="currentView === 'home'" @navigate @add-to-cart="addToCart" key="home" />
|
||||||
|
<referenzen-page v-else-if="currentView === 'referenzen'" key="referenzen" />
|
||||||
|
<kontakt-page v-else-if="currentView === 'kontakt'" key="kontakt" />
|
||||||
|
<mietkatalog-page v-else-if="currentView === 'mietkatalog'" @navigate @add-to-cart="addToCart" key="mietkatalog" />
|
||||||
|
<equipment-detail-page v-else-if="currentView === 'equipment-detail'" :id="detailId" :cart="cart" @navigate @add-to-cart="addToCart" :key="'detail-' + detailId" />
|
||||||
|
<warenkorb-page v-else-if="currentView === 'warenkorb'" :cart="cart" @remove-item="removeItem" @update-quantity="updateQuantity" @navigate @clear-cart="clearCart" key="warenkorb" />
|
||||||
|
<admin-page v-else-if="currentView === 'admin'" key="admin" />
|
||||||
|
<not-found-page v-else key="404" />
|
||||||
|
</transition>
|
||||||
|
</main>
|
||||||
|
<app-footer @navigate />
|
||||||
|
</div>`
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.component('hms-logo', HmsLogo);
|
||||||
|
app.component('speaker-icon', SpeakerIcon);
|
||||||
|
app.component('app-header', AppHeader);
|
||||||
|
app.component('app-footer', AppFooter);
|
||||||
|
app.component('service-card', ServiceCard);
|
||||||
|
app.component('equipment-card', EquipmentCard);
|
||||||
|
app.component('loading-skeleton', LoadingSkeleton);
|
||||||
|
app.component('empty-state', EmptyState);
|
||||||
|
app.component('error-state', ErrorState);
|
||||||
|
app.component('home-page', HomePage);
|
||||||
|
app.component('referenzen-page', ReferenzenPage);
|
||||||
|
app.component('kontakt-page', KontaktPage);
|
||||||
|
app.component('mietkatalog-page', MietkatalogPage);
|
||||||
|
app.component('equipment-detail-page', EquipmentDetailPage);
|
||||||
|
app.component('warenkorb-page', WarenkorbPage);
|
||||||
|
app.component('admin-page', AdminPage);
|
||||||
|
app.component('not-found-page', NotFoundPage);
|
||||||
|
app.mount('#app');
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet">
|
||||||
|
<meta name="description" content="HMS Licht & Ton GbR – Veranstaltungstechnik aus Leipheim/Ellzee. Vermietung, Verkauf, Personal, Transport, Installation von Tontechnik, Lichttechnik und Rigging.">
|
||||||
|
<meta name="author" content="Hammerschmidt u. Mössle GbR">
|
||||||
|
<meta name="theme-color" content="#EC6925">
|
||||||
|
|
||||||
|
<!-- OpenGraph Tags -->
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:title" content="HMS Licht & Ton – Veranstaltungstechnik">
|
||||||
|
<meta property="og:description" content="Vermietung, Verkauf und Installation von Veranstaltungstechnik. Über 20 Jahre Erfahrung aus Leipheim/Ellzee.">
|
||||||
|
<meta property="og:locale" content="de_DE">
|
||||||
|
<meta property="og:site_name" content="HMS Licht & Ton">
|
||||||
|
<meta property="og:url" content="https://hms.media-on.de">
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="HMS Licht & Ton – Veranstaltungstechnik">
|
||||||
|
<meta name="twitter:description" content="Vermietung, Verkauf und Installation von Veranstaltungstechnik aus Leipheim/Ellzee.">
|
||||||
|
|
||||||
|
<!-- JSON-LD Structured Data -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "LocalBusiness",
|
||||||
|
"@id": "https://hms.media-on.de/#organization",
|
||||||
|
"name": "HMS Licht & Ton",
|
||||||
|
"legalName": "Hammerschmidt u. Mössle GbR",
|
||||||
|
"description": "Veranstaltungstechnik – Vermietung, Verkauf, Personal, Transport, Installation",
|
||||||
|
"url": "https://hms.media-on.de",
|
||||||
|
"logo": "https://hms.media-on.de/logo.svg",
|
||||||
|
"telephone": "+49 8221 204433",
|
||||||
|
"email": "info@hms-licht-ton.de",
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"streetAddress": "Grockelhofen 10",
|
||||||
|
"addressLocality": "Leipheim",
|
||||||
|
"postalCode": "89340",
|
||||||
|
"addressCountry": "DE"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"@type": "Place",
|
||||||
|
"name": "Lager Ellzee",
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"streetAddress": "Zur Schönhalde 8",
|
||||||
|
"addressLocality": "Ellzee",
|
||||||
|
"postalCode": "89352",
|
||||||
|
"addressCountry": "DE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"openingHoursSpecification": {
|
||||||
|
"@type": "OpeningHoursSpecification",
|
||||||
|
"dayOfWeek": ["Monday","Tuesday","Wednesday","Thursday","Friday"],
|
||||||
|
"opens": "10:00",
|
||||||
|
"closes": "18:00"
|
||||||
|
},
|
||||||
|
"sameAs": [
|
||||||
|
"https://facebook.com/hmslichtton",
|
||||||
|
"https://instagram.com/hmslichtton"
|
||||||
|
],
|
||||||
|
"makesOffer": [
|
||||||
|
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Equipment-Vermietung"}},
|
||||||
|
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Equipment-Verkauf"}},
|
||||||
|
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Personal-Service"}},
|
||||||
|
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Transport"}},
|
||||||
|
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Lagerung"}},
|
||||||
|
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Werkstatt"}},
|
||||||
|
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Installation"}},
|
||||||
|
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Booking"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<title>HMS Licht & Ton – Veranstaltungstechnik Leipheim/Ellzee</title>
|
||||||
|
|
||||||
|
<!-- Tailwind CSS CDN (Production uses Tailwind via Nuxt 3) -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: '#EC6925',
|
||||||
|
'primary-hover': '#d4581a',
|
||||||
|
'primary-light': '#fef0e8'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Google Fonts: Inter -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Custom Design Tokens -->
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Vue 3 mounts here -->
|
||||||
|
<div class="flex items-center justify-center min-h-screen" role="status" aria-live="polite" aria-busy="true">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="hms-spinner mx-auto mb-4" style="width:40px;height:40px;border-width:4px"></div>
|
||||||
|
<p class="text-gray-400 text-sm">Laden...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vue 3 CDN (Production uses Nuxt 3 / Vue 3) -->
|
||||||
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||||
|
|
||||||
|
<!-- App Components & Router -->
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
coverage
|
||||||
@@ -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,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,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>
|
||||||
@@ -2,16 +2,14 @@ import type { $Fetch } from "nitropack";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Base API client composable.
|
* Base API client composable.
|
||||||
* Wraps $fetch with the configured API base URL from runtime config.
|
* Uses server-side apiBase for SSR requests, public apiBase for client-side.
|
||||||
* Provides typed GET, POST, PUT, DELETE helpers.
|
|
||||||
*/
|
*/
|
||||||
export function useApi() {
|
export function useApi() {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const apiBase = config.public.apiBase;
|
// On server: config.apiBase (http://backend:8000)
|
||||||
|
// On client: config.public.apiBase (/api)
|
||||||
|
const apiBase = import.meta.server ? config.apiBase : config.public.apiBase;
|
||||||
|
|
||||||
/**
|
|
||||||
* Typed $fetch wrapper bound to API base URL.
|
|
||||||
*/
|
|
||||||
const client: $Fetch = $fetch.create({
|
const client: $Fetch = $fetch.create({
|
||||||
baseURL: apiBase,
|
baseURL: apiBase,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -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>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen flex flex-col bg-bg text-text">
|
<div class="min-h-screen flex flex-col">
|
||||||
<a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a>
|
<a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<main id="main-content" class="flex-1 w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<main id="main-content" class="flex-1" role="main">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
@@ -3,6 +3,7 @@ import type { NuxtConfig } from "nuxt/schema";
|
|||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
devtools: { enabled: false },
|
devtools: { enabled: false },
|
||||||
|
ssr: false,
|
||||||
modules: ["@nuxtjs/tailwindcss", "@pinia/nuxt"],
|
modules: ["@nuxtjs/tailwindcss", "@pinia/nuxt"],
|
||||||
css: ["~/assets/css/main.css"],
|
css: ["~/assets/css/main.css"],
|
||||||
app: {
|
app: {
|
||||||
@@ -51,12 +52,15 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
apiBase: process.env.API_BASE_URL || "http://localhost:8000",
|
// Server-only: used by SSR to call backend in Docker network
|
||||||
|
apiBase: process.env.API_BASE_URL || "http://backend:8000",
|
||||||
public: {
|
public: {
|
||||||
apiBase: process.env.API_BASE_URL || "http://localhost:8000",
|
// Client-side: relative URL goes through Nuxt server proxy
|
||||||
|
apiBase: "/api",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
routeRules: {
|
routeRules: {
|
||||||
|
"/": { ssr: false },
|
||||||
"/mietkatalog": { ssr: true },
|
"/mietkatalog": { ssr: true },
|
||||||
"/mietkatalog/**": { ssr: true },
|
"/mietkatalog/**": { ssr: true },
|
||||||
"/warenkorb": { ssr: false },
|
"/warenkorb": { ssr: false },
|
||||||
@@ -73,5 +77,11 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
nitro: {
|
nitro: {
|
||||||
compressPublicAssets: true,
|
compressPublicAssets: true,
|
||||||
|
routeRules: {
|
||||||
|
"/": { ssr: false },
|
||||||
|
"/api/**": {
|
||||||
|
proxy: "http://backend:8000/api/**",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -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,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,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,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`
|
||||||
+26
-19
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
describe("Design Tokens", () => {
|
describe("Design Tokens (prototype-style.css)", () => {
|
||||||
const cssPath = resolve(__dirname, "../../assets/css/main.css");
|
const cssPath = resolve(__dirname, "../../assets/css/main.css");
|
||||||
const cssContent = readFileSync(cssPath, "utf-8");
|
const cssContent = readFileSync(cssPath, "utf-8");
|
||||||
|
|
||||||
@@ -22,14 +22,6 @@ describe("Design Tokens", () => {
|
|||||||
expect(cssContent).toContain("--surface: #212121");
|
expect(cssContent).toContain("--surface: #212121");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should define --card variable in :root", () => {
|
|
||||||
expect(cssContent).toContain("--card: #2d2d2d");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should define --border variable in :root", () => {
|
|
||||||
expect(cssContent).toContain("--border: #444444a8");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should define --text variable in :root", () => {
|
it("should define --text variable in :root", () => {
|
||||||
expect(cssContent).toContain("--text: #ffffff");
|
expect(cssContent).toContain("--text: #ffffff");
|
||||||
});
|
});
|
||||||
@@ -47,6 +39,31 @@ describe("Design Tokens", () => {
|
|||||||
it("should define transition tokens", () => {
|
it("should define transition tokens", () => {
|
||||||
expect(cssContent).toContain("--transition-fast: 150ms ease");
|
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", () => {
|
describe("Tailwind Config", () => {
|
||||||
@@ -58,14 +75,6 @@ describe("Tailwind Config", () => {
|
|||||||
expect(configContent).toContain("#EC6925");
|
expect(configContent).toContain("#EC6925");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should extend colors with bg", () => {
|
|
||||||
expect(configContent).toContain('bg: "#131313"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should extend colors with panel", () => {
|
|
||||||
expect(configContent).toContain('panel: "#1a1a1a"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should configure Inter font family", () => {
|
it("should configure Inter font family", () => {
|
||||||
expect(configContent).toContain("Inter");
|
expect(configContent).toContain("Inter");
|
||||||
});
|
});
|
||||||
@@ -78,8 +87,6 @@ describe("Nuxt Config", () => {
|
|||||||
it("should include noindex robots meta tag", () => {
|
it("should include noindex robots meta tag", () => {
|
||||||
expect(configContent).toContain("noindex");
|
expect(configContent).toContain("noindex");
|
||||||
expect(configContent).toContain("nofollow");
|
expect(configContent).toContain("nofollow");
|
||||||
expect(configContent).toContain("noarchive");
|
|
||||||
expect(configContent).toContain("nosnippet");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should include JSON-LD LocalBusiness script", () => {
|
it("should include JSON-LD LocalBusiness script", () => {
|
||||||
@@ -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">');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,13 +2,12 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
describe("AppHeader Component", () => {
|
describe("AppHeader Component (prototype port)", () => {
|
||||||
const componentPath = resolve(__dirname, "../../components/AppHeader.vue");
|
const componentPath = resolve(__dirname, "../../components/AppHeader.vue");
|
||||||
const content = readFileSync(componentPath, "utf-8");
|
const content = readFileSync(componentPath, "utf-8");
|
||||||
|
|
||||||
it("should contain sticky positioning", () => {
|
it("should have hms-header class", () => {
|
||||||
expect(content).toContain("sticky");
|
expect(content).toContain("hms-header");
|
||||||
expect(content).toContain("top-0");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have nav items: Home, Referenzen, Mietkatalog, Kontakt", () => {
|
it("should have nav items: Home, Referenzen, Mietkatalog, Kontakt", () => {
|
||||||
@@ -18,16 +17,13 @@ describe("AppHeader Component", () => {
|
|||||||
expect(content).toContain("Kontakt");
|
expect(content).toContain("Kontakt");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should include phone link tel:+491726264796", () => {
|
it("should have Warenkorb button with hms-btn-primary", () => {
|
||||||
expect(content).toContain("tel:+491726264796");
|
expect(content).toContain("Warenkorb");
|
||||||
|
expect(content).toContain("hms-btn-primary");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should include Facebook social icon link", () => {
|
it("should have hms-nav-btn class for nav items", () => {
|
||||||
expect(content).toContain("facebook.com");
|
expect(content).toContain("hms-nav-btn");
|
||||||
});
|
|
||||||
|
|
||||||
it("should include Instagram social icon link", () => {
|
|
||||||
expect(content).toContain("instagram.com");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have mobile burger menu with aria-expanded", () => {
|
it("should have mobile burger menu with aria-expanded", () => {
|
||||||
@@ -35,21 +31,33 @@ describe("AppHeader Component", () => {
|
|||||||
expect(content).toContain("mobileMenuOpen");
|
expect(content).toContain("mobileMenuOpen");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have min touch target height for nav items", () => {
|
it("should use HmsLogo component", () => {
|
||||||
expect(content).toContain("min-h-44px");
|
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", () => {
|
describe("AppFooter Component (prototype port)", () => {
|
||||||
const componentPath = resolve(__dirname, "../../components/AppFooter.vue");
|
const componentPath = resolve(__dirname, "../../components/AppFooter.vue");
|
||||||
const content = readFileSync(componentPath, "utf-8");
|
const content = readFileSync(componentPath, "utf-8");
|
||||||
|
|
||||||
|
it("should have hms-footer class", () => {
|
||||||
|
expect(content).toContain("hms-footer");
|
||||||
|
});
|
||||||
|
|
||||||
it("should contain address with Leipheim", () => {
|
it("should contain address with Leipheim", () => {
|
||||||
expect(content).toContain("89340");
|
expect(content).toContain("89340");
|
||||||
expect(content).toContain("Leipheim");
|
expect(content).toContain("Leipheim");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should contain phone number +49 8221 204433", () => {
|
it("should contain phone number", () => {
|
||||||
expect(content).toContain("tel:+498221204433");
|
expect(content).toContain("tel:+498221204433");
|
||||||
expect(content).toContain("204433");
|
expect(content).toContain("204433");
|
||||||
});
|
});
|
||||||
@@ -73,20 +81,29 @@ describe("AppFooter Component", () => {
|
|||||||
expect(content).toContain("/agb-vermietung");
|
expect(content).toContain("/agb-vermietung");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should NOT contain 'Neu in der Vermietung'", () => {
|
it("should contain Admin-Login link", () => {
|
||||||
expect(content).not.toContain("Neu in der Vermietung");
|
expect(content).toContain("Admin-Login");
|
||||||
});
|
expect(content).toContain("/admin");
|
||||||
|
|
||||||
it("should NOT contain 'AGB Shop'", () => {
|
|
||||||
expect(content).not.toContain("AGB Shop");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have 4-column grid layout", () => {
|
it("should have 4-column grid layout", () => {
|
||||||
expect(content).toContain("lg:grid-cols-4");
|
expect(content).toContain("lg:grid-cols-4");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should include Facebook social icon link", () => {
|
||||||
|
expect(content).toContain("facebook.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Error Page (404)", () => {
|
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 errorPath = resolve(__dirname, "../../error.vue");
|
||||||
const content = readFileSync(errorPath, "utf-8");
|
const content = readFileSync(errorPath, "utf-8");
|
||||||
|
|
||||||
@@ -94,17 +111,27 @@ describe("Error Page (404)", () => {
|
|||||||
expect(content).toContain("Seite nicht gefunden");
|
expect(content).toContain("Seite nicht gefunden");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should contain link to home /", () => {
|
it("should contain 404 text", () => {
|
||||||
|
expect(content).toContain("404");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should contain link to home", () => {
|
||||||
expect(content).toContain('to="/"');
|
expect(content).toContain('to="/"');
|
||||||
|
expect(content).toContain("Zur Startseite");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should include noindex meta tag", () => {
|
it("should include noindex meta tag", () => {
|
||||||
expect(content).toContain("noindex");
|
expect(content).toContain("noindex");
|
||||||
expect(content).toContain("nofollow");
|
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", () => {
|
describe("HmsLogo Component (prototype port)", () => {
|
||||||
const logoPath = resolve(__dirname, "../../components/HmsLogo.vue");
|
const logoPath = resolve(__dirname, "../../components/HmsLogo.vue");
|
||||||
const content = readFileSync(logoPath, "utf-8");
|
const content = readFileSync(logoPath, "utf-8");
|
||||||
|
|
||||||
@@ -116,9 +143,13 @@ describe("HmsLogo Component", () => {
|
|||||||
expect(content).toContain("#EC6925");
|
expect(content).toContain("#EC6925");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should contain border/stroke with accent color", () => {
|
it("should have hms-logo-svg class", () => {
|
||||||
expect(content).toContain("accentColor");
|
expect(content).toContain("hms-logo-svg");
|
||||||
expect(content).toContain("stroke");
|
});
|
||||||
|
|
||||||
|
it("should accept size prop with default 40", () => {
|
||||||
|
expect(content).toContain("size");
|
||||||
|
expect(content).toContain("40");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
@@ -1,12 +1,3 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.nuxt
|
|
||||||
.output
|
|
||||||
dist
|
dist
|
||||||
.git
|
.git
|
||||||
.gitignore
|
|
||||||
*.md
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
playwright-report
|
|
||||||
test-results
|
|
||||||
coverage
|
|
||||||
|
|||||||
+9
-16
@@ -1,21 +1,14 @@
|
|||||||
# --- Stage 1: Build ---
|
# Stage 1: Build
|
||||||
FROM node:20 AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
COPY package.json package-lock.json ./
|
RUN npm ci || npm install
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# --- Stage 2: Production ---
|
# Stage 2: Serve with Nginx
|
||||||
FROM node:20-slim AS production
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
WORKDIR /app
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
COPY --from=builder /app/.output ./.output
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
CMD ["node", ".output/server/index.mjs"]
|
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
:root {
|
|
||||||
--bg: #131313;
|
|
||||||
--panel: #1a1a1a;
|
|
||||||
--surface: #212121;
|
|
||||||
--row: #272727;
|
|
||||||
--card: #2d2d2d;
|
|
||||||
--border: #444444a8;
|
|
||||||
--border-strong: #555555;
|
|
||||||
--secondary: #656565;
|
|
||||||
--primary: #737a81;
|
|
||||||
--text: #ffffff;
|
|
||||||
--text-muted: #d4d4d4;
|
|
||||||
--color-accent: #EC6925;
|
|
||||||
--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;
|
|
||||||
--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-info: #60a5fa;
|
|
||||||
--radius-sm: 2px;
|
|
||||||
--radius-md: 3px;
|
|
||||||
--radius-lg: 4px;
|
|
||||||
--radius-xl: 4px;
|
|
||||||
--radius-full: 9999px;
|
|
||||||
--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;
|
|
||||||
--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);
|
|
||||||
--transition-fast: 150ms ease;
|
|
||||||
--transition-base: 250ms ease;
|
|
||||||
--transition-slow: 400ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: "Inter", system-ui, sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
line-height: 1.2;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
*:focus-visible {
|
|
||||||
outline: 2px solid var(--color-accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color var(--transition-fast);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
|
||||||
.skip-link {
|
|
||||||
position: absolute;
|
|
||||||
left: -9999px;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--color-accent);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip-link:focus {
|
|
||||||
left: 0.5rem;
|
|
||||||
top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
position: relative;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
transition: color var(--transition-fast);
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
min-height: 44px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover {
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 2px;
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
transition: width var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover::after,
|
|
||||||
.nav-link.router-link-active::after {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link.router-link-active {
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
color: #ffffff;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 0.625rem 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
transition: background-color var(--transition-fast);
|
|
||||||
min-height: 44px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: var(--color-accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background-color: var(--surface);
|
|
||||||
color: var(--text-muted);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 0.625rem 1.5rem;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
min-height: 44px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
border-color: var(--color-accent-border);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { background-position: -200% 0; }
|
|
||||||
100% { background-position: 200% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-shimmer {
|
|
||||||
background: linear-gradient(90deg, var(--panel) 25%, var(--surface) 50%, var(--panel) 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: shimmer 1.5s infinite;
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
<template>
|
|
||||||
<footer
|
|
||||||
class="bg-bg border-t border-border-default mt-auto"
|
|
||||||
role="contentinfo"
|
|
||||||
>
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
|
||||||
<!-- Column 1: Navigation -->
|
|
||||||
<div>
|
|
||||||
<h2 class="text-sm font-bold text-text uppercase tracking-wider mb-4">Navigation</h2>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<li>
|
|
||||||
<NuxtLink to="/" class="text-text-muted hover:text-accent text-sm transition-colors duration-fast">Home</NuxtLink>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<NuxtLink to="/referenzen" class="text-text-muted hover:text-accent text-sm transition-colors duration-fast">Referenzen</NuxtLink>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<NuxtLink to="/mietkatalog" class="text-text-muted hover:text-accent text-sm transition-colors duration-fast">Mietkatalog</NuxtLink>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<NuxtLink to="/kontakt" class="text-text-muted hover:text-accent text-sm transition-colors duration-fast">Kontakt</NuxtLink>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Column 2: Kontakt -->
|
|
||||||
<div>
|
|
||||||
<h2 class="text-sm font-bold text-text uppercase tracking-wider mb-4">Kontakt</h2>
|
|
||||||
<address class="not-italic text-sm text-text-muted space-y-2">
|
|
||||||
<p>Grockelhofen 10<br>89340 Leipheim</p>
|
|
||||||
<p>
|
|
||||||
<a href="tel:+498221204433" class="hover:text-accent transition-colors duration-fast">
|
|
||||||
+49 (0) 8221 / 204433
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<a href="mailto:info@hms-licht-ton.de" class="hover:text-accent transition-colors duration-fast">
|
|
||||||
info@hms-licht-ton.de
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</address>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Column 3: Rechtliches -->
|
|
||||||
<div>
|
|
||||||
<h2 class="text-sm font-bold text-text uppercase tracking-wider mb-4">Rechtliches</h2>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<li>
|
|
||||||
<NuxtLink to="/impressum" class="text-text-muted hover:text-accent text-sm transition-colors duration-fast">Impressum</NuxtLink>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<NuxtLink to="/datenschutz" class="text-text-muted hover:text-accent text-sm transition-colors duration-fast">DSGVO</NuxtLink>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<NuxtLink to="/agb-vermietung" class="text-text-muted hover:text-accent text-sm transition-colors duration-fast">AGB Vermietung</NuxtLink>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Column 4: Social -->
|
|
||||||
<div>
|
|
||||||
<h2 class="text-sm font-bold text-text uppercase tracking-wider mb-4">Folgen Sie uns</h2>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<a
|
|
||||||
href="https://www.facebook.com/hms.licht.ton"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-secondary hover:text-accent transition-colors duration-flex"
|
|
||||||
aria-label="Facebook"
|
|
||||||
>
|
|
||||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M24 12.07C24 5.4 18.63 0 12 0S0 5.4 0 12.07C0 18.1 4.39 23.1 10.13 24v-8.44H7.08v-3.49h3.05V9.41c0-3.02 1.79-4.69 4.53-4.69 1.31 0 2.68.24 2.68.24v2.97h-1.51c-1.49 0-1.96.93-1.96 1.89v2.25h3.33l-.53 3.49h-2.8V24C19.61 23.1 24 18.1 24 12.07z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://www.instagram.com/hms.licht.ton"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-secondary hover:text-accent transition-colors duration-fast"
|
|
||||||
aria-label="Instagram"
|
|
||||||
>
|
|
||||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M12 2.16c3.2 0 3.58.01 4.85.07 1.17.05 1.8.25 2.23.41.56.22.96.48 1.38.9.42.42.68.82.9 1.38.16.42.36 1.06.41 2.23.06 1.27.07 1.65.07 4.85s-.01 3.58-.07 4.85c-.05 1.17-.25 1.8-.41 2.23-.22.56-.48.96-.9 1.38-.42.42-.82.68-1.38.9-.42.16-1.06.36-2.23.41-1.27.06-1.65.07-4.85.07s-3.58-.01-4.85-.07c-1.17-.05-1.8-.25-2.23-.41a3.7 3.7 0 01-1.38-.9 3.7 3.7 0 01-.9-1.38c-.16-.42-.36-1.06-.41-2.23C2.17 15.58 2.16 15.2 2.16 12s.01-3.58.07-4.85c.05-1.17.25-1.8.41-2.23.22-.56.48-.96.9-1.38.42-.42.82-.68 1.38-.9.42-.16 1.06-.36 2.23-.41C8.42 2.17 8.8 2.16 12 2.16zM12 0C8.74 0 8.33.01 7.05.07 5.78.13 4.9.33 4.14.63a5.9 5.9 0 00-2.13 1.38A5.9 5.9 0 00.63 4.14C.33 4.9.13 5.78.07 7.05.01 8.33 0 8.74 0 12s.01 3.67.07 4.95c.06 1.27.26 2.15.56 2.91a5.9 5.9 0 001.38 2.13 5.9 5.9 0 002.13 1.38c.76.3 1.64.5 2.91.56C8.33 23.99 8.74 24 12 24s3.67-.01 4.95-.07c1.27-.06 2.15-.26 2.91-.56a5.9 5.9 0 002.13-1.38 5.9 5.9 0 001.38-2.13c.3-.76.5-1.64.56-2.91.06-1.28.07-1.69.07-4.95s-.01-3.67-.07-4.95c-.06-1.27-.26-2.15-.56-2.91a5.9 5.9 0 00-1.38-2.13A5.9 5.9 0 0019.86.63c-.76-.3-1.64-.5-2.91-.56C15.67.01 15.26 0 12 0z"/>
|
|
||||||
<path d="M12 5.84A6.16 6.16 0 1018.16 12 6.16 6.16 0 0012 5.84zm0 10.16A4 4 0 1116 12a4 4 0 01-4 4z"/>
|
|
||||||
<circle cx="18.41" cy="5.59" r="1.44"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Copyright -->
|
|
||||||
<div class="mt-8 pt-6 border-t border-border-default">
|
|
||||||
<p class="text-xs text-secondary text-center">
|
|
||||||
© {{ new Date().getFullYear() }} Hammerschmidt u. Mössle GbR · Veranstaltungstechnik · Leipheim / Ellzee
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// AppFooter – 4-column footer with navigation, contact, legal links, and social icons
|
|
||||||
</script>
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
<template>
|
|
||||||
<header
|
|
||||||
class="sticky top-0 z-50 bg-panel border-b border-border-default"
|
|
||||||
role="banner"
|
|
||||||
>
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex items-center justify-between h-14 lg:h-16">
|
|
||||||
<!-- Logo -->
|
|
||||||
<NuxtLink to="/" class="flex items-center gap-2 shrink-0" aria-label="HMS Licht & Ton Startseite">
|
|
||||||
<HmsLogo />
|
|
||||||
<span class="hidden sm:inline font-bold text-sm tracking-wide text-text">
|
|
||||||
HMS Licht & Ton
|
|
||||||
</span>
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<!-- Desktop Navigation -->
|
|
||||||
<nav class="hidden md:flex items-center gap-6" role="navigation" aria-label="Hauptnavigation">
|
|
||||||
<NuxtLink to="/" class="nav-link">Home</NuxtLink>
|
|
||||||
<NuxtLink to="/referenzen" class="nav-link">Referenzen</NuxtLink>
|
|
||||||
<NuxtLink to="/mietkatalog" class="nav-link">Mietkatalog</NuxtLink>
|
|
||||||
<NuxtLink to="/kontakt" class="nav-link">Kontakt</NuxtLink>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Desktop Right: Phone + Social + Cart -->
|
|
||||||
<div class="hidden md:flex items-center gap-4">
|
|
||||||
<a
|
|
||||||
href="tel:+491726264796"
|
|
||||||
class="flex items-center gap-1.5 text-text-muted hover:text-accent transition-colors duration-fast text-sm font-medium min-h-44px"
|
|
||||||
aria-label="Telefon anrufen"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 5a2 2 0 012-2h2.5a1 1 0 01.95.68l1.2 3.6a1 1 0 01-.27 1.02L7.5 9.5a12 12 0 005 5l1.2-1.13a1 1 0 011.02-.27l3.6 1.2a1 1 0 01.68.95V19a2 2 0 01-2 2A16 16 0 013 5z"/>
|
|
||||||
</svg>
|
|
||||||
<span>+49 172 6264796</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Cart Icon with Counter Badge -->
|
|
||||||
<button
|
|
||||||
class="relative flex items-center justify-center w-10 h-10 text-text-muted hover:text-accent transition-colors duration-fast min-h-44px"
|
|
||||||
aria-label="Warenkorb öffnen"
|
|
||||||
data-testid="header-cart-button"
|
|
||||||
@click="cartDrawerOpen = true"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 00-16.536-1.84M7.5 14.25L5.106 5.272M6 20.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm12.75 0a.75.75 0 11-1.5 0 .75.75 0 011.5 0z" />
|
|
||||||
</svg>
|
|
||||||
<span
|
|
||||||
v-if="cartCount > 0"
|
|
||||||
class="absolute -top-1 -right-1 bg-accent text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
|
|
||||||
data-testid="header-cart-count"
|
|
||||||
>{{ cartCount }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://www.facebook.com/hms.licht.ton"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-secondary hover:text-accent transition-colors duration-fast min-h-44px flex items-center"
|
|
||||||
aria-label="Facebook"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M24 12.07C24 5.4 18.63 0 12 0S0 5.4 0 12.07C0 18.1 4.39 23.1 10.13 24v-8.44H7.08v-3.49h3.05V9.41c0-3.02 1.79-4.69 4.53-4.69 1.31 0 2.68.24 2.68.24v2.97h-1.51c-1.49 0-1.96.93-1.96 1.89v2.25h3.33l-.53 3.49h-2.8V24C19.61 23.1 24 18.1 24 12.07z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://www.instagram.com/hms.licht.ton"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-secondary hover:text-accent transition-colors duration-fast min-h-44px flex items-center"
|
|
||||||
aria-label="Instagram"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M12 2.16c3.2 0 3.58.01 4.85.07 1.17.05 1.8.25 2.23.41.56.22.96.48 1.38.9.42.42.68.82.9 1.38.16.42.36 1.06.41 2.23.06 1.27.07 1.65.07 4.85s-.01 3.58-.07 4.85c-.05 1.17-.25 1.8-.41 2.23-.22.56-.48.96-.9 1.38-.42.42-.82.68-1.38.9-.42.16-1.06.36-2.23.41-1.27.06-1.65.07-4.85.07s-3.58-.01-4.85-.07c-1.17-.05-1.8-.25-2.23-.41a3.7 3.7 0 01-1.38-.9 3.7 3.7 0 01-.9-1.38c-.16-.42-.36-1.06-.41-2.23C2.17 15.58 2.16 15.2 2.16 12s.01-3.58.07-4.85c.05-1.17.25-1.8.41-2.23.22-.56.48-.96.9-1.38.42-.42.82-.68 1.38-.9.42-.16 1.06-.36 2.23-.41C8.42 2.17 8.8 2.16 12 2.16zM12 0C8.74 0 8.33.01 7.05.07 5.78.13 4.9.33 4.14.63a5.9 5.9 0 00-2.13 1.38A5.9 5.9 0 00.63 4.14C.33 4.9.13 5.78.07 7.05.01 8.33 0 8.74 0 12s.01 3.67.07 4.95c.06 1.27.26 2.15.56 2.91a5.9 5.9 0 001.38 2.13 5.9 5.9 0 002.13 1.38c.76.3 1.64.5 2.91.56C8.33 23.99 8.74 24 12 24s3.67-.01 4.95-.07c1.27-.06 2.15-.26 2.91-.56a5.9 5.9 0 002.13-1.38 5.9 5.9 0 001.38-2.13c.3-.76.5-1.64.56-2.91.06-1.28.07-1.69.07-4.95s-.01-3.67-.07-4.95c-.06-1.27-.26-2.15-.56-2.91a5.9 5.9 0 00-1.38-2.13A5.9 5.9 0 0019.86.63c-.76-.3-1.64-.5-2.91-.56C15.67.01 15.26 0 12 0z"/>
|
|
||||||
<path d="M12 5.84A6.16 6.16 0 1018.16 12 6.16 6.16 0 0012 5.84zm0 10.16A4 4 0 1116 12a4 4 0 01-4 4z"/>
|
|
||||||
<circle cx="18.41" cy="5.59" r="1.44"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile Cart Button -->
|
|
||||||
<button
|
|
||||||
class="md:hidden relative flex items-center justify-center w-11 h-11 text-text-muted hover:text-accent transition-colors duration-fast"
|
|
||||||
aria-label="Warenkorb öffnen"
|
|
||||||
data-testid="header-cart-button-mobile"
|
|
||||||
@click="cartDrawerOpen = true"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 00-16.536-1.84M7.5 14.25L5.106 5.272M6 20.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm12.75 0a.75.75 0 11-1.5 0 .75.75 0 011.5 0z" />
|
|
||||||
</svg>
|
|
||||||
<span
|
|
||||||
v-if="cartCount > 0"
|
|
||||||
class="absolute -top-1 -right-1 bg-accent text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
|
|
||||||
data-testid="header-cart-count-mobile"
|
|
||||||
>{{ cartCount }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Mobile Burger Button -->
|
|
||||||
<button
|
|
||||||
class="md:hidden flex items-center justify-center w-11 h-11 text-text-muted hover:text-accent transition-colors duration-fast"
|
|
||||||
:aria-expanded="mobileMenuOpen"
|
|
||||||
aria-controls="mobile-nav"
|
|
||||||
aria-label="Menü öffnen/schließen"
|
|
||||||
@click="toggleMobileMenu"
|
|
||||||
>
|
|
||||||
<svg v-if="!mobileMenuOpen" class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
|
|
||||||
</svg>
|
|
||||||
<svg v-else class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6l12 12M18 6L6 18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile Navigation -->
|
|
||||||
<nav
|
|
||||||
v-if="mobileMenuOpen"
|
|
||||||
id="mobile-nav"
|
|
||||||
class="md:hidden bg-panel border-t border-border-default"
|
|
||||||
role="navigation"
|
|
||||||
aria-label="Mobile Navigation"
|
|
||||||
>
|
|
||||||
<div class="px-4 py-3 space-y-1">
|
|
||||||
<NuxtLink to="/" class="block nav-link px-3 py-3 min-h-44px" @click="closeMobileMenu">Home</NuxtLink>
|
|
||||||
<NuxtLink to="/referenzen" class="block nav-link px-3 py-3 min-h-44px" @click="closeMobileMenu">Referenzen</NuxtLink>
|
|
||||||
<NuxtLink to="/mietkatalog" class="block nav-link px-3 py-3 min-h-44px" @click="closeMobileMenu">Mietkatalog</NuxtLink>
|
|
||||||
<NuxtLink to="/kontakt" class="block nav-link px-3 py-3 min-h-44px" @click="closeMobileMenu">Kontakt</NuxtLink>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4 px-3 pt-4 pb-2 border-t border-border-default mt-2">
|
|
||||||
<a href="tel:+491726264796" class="flex items-center gap-1.5 text-text-muted hover:text-accent text-sm min-h-44px" @click="closeMobileMenu">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 5a2 2 0 012-2h2.5a1 1 0 01.95.68l1.2 3.6a1 1 0 01-.27 1.02L7.5 9.5a12 12 0 005 5l1.2-1.13a1 1 0 011.02-.27l3.6 1.2a1 1 0 01.68.95V19a2 2 0 01-2 2A16 16 0 013 5z"/>
|
|
||||||
</svg>
|
|
||||||
<span>+49 172 6264796</span>
|
|
||||||
</a>
|
|
||||||
<a href="https://www.facebook.com/hms.licht.ton" target="_blank" rel="noopener noreferrer" class="text-secondary hover:text-accent min-h-44px flex items-center" aria-label="Facebook" @click="closeMobileMenu">
|
|
||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M24 12.07C24 5.4 18.63 0 12 0S0 5.4 0 12.07C0 18.1 4.39 23.1 10.13 24v-8.44H7.08v-3.49h3.05V9.41c0-3.02 1.79-4.69 4.53-4.69 1.31 0 2.68.24 2.68.24v2.97h-1.51c-1.49 0-1.96.93-1.96 1.89v2.25h3.33l-.53 3.49h-2.8V24C19.61 23.1 24 18.1 24 12.07z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a href="https://www.instagram.com/hms.licht.ton" target="_blank" rel="noopener noreferrer" class="text-secondary hover:text-accent min-h-44px flex items-center" aria-label="Instagram" @click="closeMobileMenu">
|
|
||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M12 2.16c3.2 0 3.58.01 4.85.07 1.17.05 1.8.25 2.23.41.56.22.96.48 1.38.9.42.42.68.82.9 1.38.16.42.36 1.06.41 2.23.06 1.27.07 1.65.07 4.85s-.01 3.58-.07 4.85c-.05 1.17-.25 1.8-.41 2.23-.22.56-.48.96-.9 1.38-.42.42-.82.68-1.38.9-.42.16-1.06.36-2.23.41-1.27.06-1.65.07-4.85.07s-3.58-.01-4.85-.07c-1.17-.05-1.8-.25-2.23-.41a3.7 3.7 0 01-1.38-.9 3.7 3.7 0 01-.9-1.38c-.16-.42-.36-1.06-.41-2.23C2.17 15.58 2.16 15.2 2.16 12s.01-3.58.07-4.85c.05-1.17.25-1.8.41-2.23.22-.56.48-.96.9-1.38.42-.42.82-.68 1.38-.9.42-.16 1.06-.36 2.23-.41C8.42 2.17 8.8 2.16 12 2.16zM12 0C8.74 0 8.33.01 7.05.07 5.78.13 4.9.33 4.14.63a5.9 5.9 0 00-2.13 1.38A5.9 5.9 0 00.63 4.14C.33 4.9.13 5.78.07 7.05.01 8.33 0 8.74 0 12s.01 3.67.07 4.95c.06 1.27.26 2.15.56 2.91a5.9 5.9 0 001.38 2.13 5.9 5.9 0 002.13 1.38c.76.3 1.64.5 2.91.56C8.33 23.99 8.74 24 12 24s3.67-.01 4.95-.07c1.27-.06 2.15-.26 2.91-.56a5.9 5.9 0 002.13-1.38 5.9 5.9 0 001.38-2.13c.3-.76.5-1.64.56-2.91.06-1.28.07-1.69.07-4.95s-.01-3.67-.07-4.95c-.06-1.27-.26-2.15-.56-2.91a5.9 5.9 0 00-1.38-2.13A5.9 5.9 0 0019.86.63c-.76-.3-1.64-.5-2.91-.56C15.67.01 15.26 0 12 0z"/>
|
|
||||||
<path d="M12 5.84A6.16 6.16 0 1018.16 12 6.16 6.16 0 0012 5.84zm0 10.16A4 4 0 1116 12a4 4 0 01-4 4z"/>
|
|
||||||
<circle cx="18.41" cy="5.59" r="1.44"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Cart Drawer -->
|
|
||||||
<CartDrawer v-model="cartDrawerOpen" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useCart } from "~/composables/useCart";
|
|
||||||
|
|
||||||
const { totalCount: cartCount } = useCart();
|
|
||||||
|
|
||||||
const mobileMenuOpen = ref(false);
|
|
||||||
const cartDrawerOpen = ref(false);
|
|
||||||
|
|
||||||
function toggleMobileMenu(): void {
|
|
||||||
mobileMenuOpen.value = !mobileMenuOpen.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMobileMenu(): void {
|
|
||||||
mobileMenuOpen.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
watch(() => route.path, () => {
|
|
||||||
closeMobileMenu();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<!-- Backdrop -->
|
|
||||||
<Transition name="cart-backdrop">
|
|
||||||
<div
|
|
||||||
v-if="modelValue"
|
|
||||||
class="fixed inset-0 z-[60] bg-black/60"
|
|
||||||
data-testid="cart-drawer-backdrop"
|
|
||||||
@click="close"
|
|
||||||
/>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<!-- Drawer Panel -->
|
|
||||||
<Transition name="cart-drawer">
|
|
||||||
<aside
|
|
||||||
v-if="modelValue"
|
|
||||||
class="fixed z-[61] bg-panel border-l border-border-default shadow-xl flex flex-col"
|
|
||||||
:class="isMobile ? 'bottom-0 left-0 right-0 rounded-t-lg max-h-[80vh]' : 'top-0 right-0 bottom-0 w-96'"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Warenkorb"
|
|
||||||
data-testid="cart-drawer"
|
|
||||||
>
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center justify-between px-4 py-3 border-b border-border-default shrink-0">
|
|
||||||
<h2 class="text-lg font-bold text-text" data-testid="cart-drawer-title">
|
|
||||||
Warenkorb
|
|
||||||
<span v-if="totalCount > 0" class="text-sm font-normal text-text-muted ml-1">({{ totalCount }})</span>
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
class="text-text-muted hover:text-accent transition-colors duration-fast w-8 h-8 flex items-center justify-center"
|
|
||||||
aria-label="Warenkorb schließen"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div v-if="isEmpty" class="flex-1 flex flex-col items-center justify-center px-6 py-12 text-center">
|
|
||||||
<div class="w-16 h-16 mb-4 flex items-center justify-center rounded-full bg-surface">
|
|
||||||
<svg class="w-8 h-8 text-secondary" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 00-16.536-1.84M7.5 14.25L5.106 5.272M6 20.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm12.75 0a.75.75 0 11-1.5 0 .75.75 0 011.5 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p class="text-text-muted text-sm mb-4">Ihr Warenkorb ist leer.</p>
|
|
||||||
<button class="btn-primary" @click="close">
|
|
||||||
Weiter einkaufen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Items List -->
|
|
||||||
<div v-else class="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="item in items"
|
|
||||||
:key="item.equipment_id"
|
|
||||||
class="flex items-center gap-3 bg-surface rounded-md p-3"
|
|
||||||
data-testid="cart-drawer-item"
|
|
||||||
>
|
|
||||||
<!-- Item Image -->
|
|
||||||
<div class="w-12 h-12 rounded-md overflow-hidden bg-card shrink-0">
|
|
||||||
<img
|
|
||||||
v-if="item.image_url"
|
|
||||||
:src="item.image_url"
|
|
||||||
:alt="item.name"
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
<div v-else class="w-full h-full flex items-center justify-center">
|
|
||||||
<svg class="w-6 h-6 text-secondary" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 3.75h16.5a1.5 1.5 0 011.5 1.5v12a1.5 1.5 0 01-1.5 1.5H3.75a1.5 1.5 0 01-1.5-1.5V5.25a1.5 1.5 0 011.5-1.5z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Item Info -->
|
|
||||||
<div class="flex-grow min-w-0">
|
|
||||||
<NuxtLink :to="`/mietkatalog/${item.equipment_id}`" class="text-sm font-medium text-text hover:text-accent transition-colors duration-fast line-clamp-1" @click="close">
|
|
||||||
{{ item.name }}
|
|
||||||
</NuxtLink>
|
|
||||||
<p class="text-xs text-text-muted">
|
|
||||||
{{ item.quantity }}×
|
|
||||||
<span v-if="item.rental_price != null">{{ formatPrice(item.rental_price) }} €/Tag</span>
|
|
||||||
<span v-else>Preis auf Anfrage</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Remove Button -->
|
|
||||||
<button
|
|
||||||
class="text-text-muted hover:text-error transition-colors duration-fast w-8 h-8 flex items-center justify-center shrink-0"
|
|
||||||
:aria-label="`${item.name} entfernen`"
|
|
||||||
data-testid="cart-drawer-remove"
|
|
||||||
@click="removeItem(item.equipment_id)"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div v-if="!isEmpty" class="shrink-0 border-t border-border-default px-4 py-3 space-y-3">
|
|
||||||
<NuxtLink to="/warenkorb" class="btn-secondary w-full" @click="close" data-testid="cart-drawer-view-cart">
|
|
||||||
Warenkorb ansehen
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink to="/mietanfrage" class="btn-primary w-full" @click="close" data-testid="cart-drawer-checkout">
|
|
||||||
Zur Mietanfrage
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useCart } from "~/composables/useCart";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
"update:modelValue": [boolean];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { items, totalCount, isEmpty, removeItem } = useCart();
|
|
||||||
|
|
||||||
const isMobile = ref(false);
|
|
||||||
|
|
||||||
function checkViewport(): void {
|
|
||||||
if (import.meta.client) {
|
|
||||||
isMobile.value = window.innerWidth < 768;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
checkViewport();
|
|
||||||
window.addEventListener("resize", checkViewport);
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (import.meta.client) {
|
|
||||||
window.removeEventListener("resize", checkViewport);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function close(): void {
|
|
||||||
emit("update:modelValue", false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPrice(price: number | null): string {
|
|
||||||
if (price == null) return "—";
|
|
||||||
return new Intl.NumberFormat("de-DE", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
}).format(price);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.cart-backdrop-enter-active,
|
|
||||||
.cart-backdrop-leave-active {
|
|
||||||
transition: opacity 0.25s ease;
|
|
||||||
}
|
|
||||||
.cart-backdrop-enter-from,
|
|
||||||
.cart-backdrop-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cart-drawer-enter-active,
|
|
||||||
.cart-drawer-leave-active {
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
.cart-drawer-enter-from,
|
|
||||||
.cart-drawer-leave-to {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<div class="w-16 h-16 mb-4 flex items-center justify-center rounded-full bg-surface">
|
|
||||||
<svg class="w-8 h-8 text-secondary" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17v-6a2 2 0 012-2h2a2 2 0 012 2v6m-6 0h6m-6 0H7m14 0a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2h12z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 class="text-lg font-bold text-text mb-2">{{ title }}</h2>
|
|
||||||
<p class="text-text-muted text-sm mb-6 max-w-sm">{{ message }}</p>
|
|
||||||
<button
|
|
||||||
v-if="ctaText"
|
|
||||||
class="btn-primary"
|
|
||||||
@click="$emit('action')"
|
|
||||||
>
|
|
||||||
{{ ctaText }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps<{
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
ctaText?: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
action: [];
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<template>
|
|
||||||
<article
|
|
||||||
class="card rounded-lg overflow-hidden border border-border-default hover:border-accent-border transition-colors duration-fast flex flex-col"
|
|
||||||
data-testid="equipment-card"
|
|
||||||
>
|
|
||||||
<!-- Image / Placeholder -->
|
|
||||||
<div class="relative w-full h-48 bg-surface overflow-hidden">
|
|
||||||
<img
|
|
||||||
v-if="equipment.image_url"
|
|
||||||
:src="equipment.image_url"
|
|
||||||
:alt="equipment.name"
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="w-full h-full flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<svg class="w-12 h-12 text-secondary" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 3.75h16.5a1.5 1.5 0 011.5 1.5v12a1.5 1.5 0 01-1.5 1.5H3.75a1.5 1.5 0 01-1.5-1.5V5.25a1.5 1.5 0 011.5-1.5z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<!-- Category Badge -->
|
|
||||||
<span
|
|
||||||
v-if="equipment.category"
|
|
||||||
class="absolute top-2 left-2 px-2 py-1 text-xs font-medium rounded-sm bg-bg/80 text-text-muted backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
{{ equipment.category }}
|
|
||||||
</span>
|
|
||||||
<!-- Availability Badge -->
|
|
||||||
<span
|
|
||||||
v-if="!equipment.available"
|
|
||||||
class="absolute top-2 right-2 px-2 py-1 text-xs font-medium rounded-sm bg-error-bg text-error"
|
|
||||||
>
|
|
||||||
Nicht verfügbar
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="flex flex-col flex-grow p-4">
|
|
||||||
<h3 class="text-base font-bold text-text mb-1 line-clamp-1">
|
|
||||||
<NuxtLink :to="`/mietkatalog/${equipment.id}`" class="hover:text-accent transition-colors duration-fast">
|
|
||||||
{{ equipment.name }}
|
|
||||||
</NuxtLink>
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-text-muted mb-3 line-clamp-2 flex-grow">
|
|
||||||
{{ equipment.description || "Keine Beschreibung verfügbar" }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Price & Button -->
|
|
||||||
<div class="flex items-center justify-between mt-2">
|
|
||||||
<span v-if="equipment.rental_price != null" class="text-sm font-semibold text-accent">
|
|
||||||
{{ formatPrice(equipment.rental_price) }} €/Tag
|
|
||||||
</span>
|
|
||||||
<span v-else class="text-sm text-text-muted">
|
|
||||||
Preis auf Anfrage
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
class="btn-primary text-xs px-3 py-2"
|
|
||||||
@click="$emit('add-to-cart', { id: equipment.id, name: equipment.name })"
|
|
||||||
>
|
|
||||||
Mietanfrage
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { EquipmentItem } from "~/composables/useEquipment";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
equipment: EquipmentItem;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
"add-to-cart": [{ id: number; name: string }];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
function formatPrice(price: number | null): string {
|
|
||||||
if (price == null) return "—";
|
|
||||||
return new Intl.NumberFormat("de-DE", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
}).format(price);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center justify-center py-12 text-center"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
<div class="w-16 h-16 mb-4 flex items-center justify-center rounded-full bg-error-bg">
|
|
||||||
<svg class="w-8 h-8 text-error" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v4m0 4h.01M4.93 19h14.14c1.54 0 2.5-1.67 1.73-3L13.73 4c-.77-1.33-2.69-1.33-3.46 0L3.2 16c-.77 1.33.19 3 1.73 3z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 class="text-lg font-bold text-text mb-2">{{ title }}</h2>
|
|
||||||
<p class="text-text-muted text-sm mb-6 max-w-sm">{{ message }}</p>
|
|
||||||
<button
|
|
||||||
v-if="retryText"
|
|
||||||
class="btn-secondary"
|
|
||||||
@click="$emit('retry')"
|
|
||||||
>
|
|
||||||
{{ retryText }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
withDefaults(defineProps<{
|
|
||||||
title?: string;
|
|
||||||
message?: string;
|
|
||||||
retryText?: string;
|
|
||||||
}>(), {
|
|
||||||
title: "Ein Fehler ist aufgetreten",
|
|
||||||
message: "Bitte versuchen Sie es erneut.",
|
|
||||||
retryText: "Erneut versuchen",
|
|
||||||
});
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
retry: [];
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg
|
|
||||||
width="40"
|
|
||||||
height="40"
|
|
||||||
viewBox="0 0 40 40"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
role="img"
|
|
||||||
aria-label="HMS Licht & Ton Logo"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x="1"
|
|
||||||
y="1"
|
|
||||||
width="38"
|
|
||||||
height="38"
|
|
||||||
rx="4"
|
|
||||||
:stroke="accentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
fill="#1a1a1a"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x="20"
|
|
||||||
y="16"
|
|
||||||
text-anchor="middle"
|
|
||||||
fill="#ffffff"
|
|
||||||
font-family="Inter, sans-serif"
|
|
||||||
font-size="11"
|
|
||||||
font-weight="800"
|
|
||||||
>HMS</text>
|
|
||||||
<path
|
|
||||||
d="M8 22 L12 26 L8 30"
|
|
||||||
:stroke="accentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M14 22 L18 26 L14 30"
|
|
||||||
:stroke="accentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M20 22 L24 26 L20 30"
|
|
||||||
:stroke="accentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x="20"
|
|
||||||
y="36"
|
|
||||||
text-anchor="middle"
|
|
||||||
fill="#d4d4d4"
|
|
||||||
font-family="Inter, sans-serif"
|
|
||||||
font-size="5"
|
|
||||||
font-weight="500"
|
|
||||||
>LICHT & TON</text>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const accentColor: string = "#EC6925";
|
|
||||||
</script>
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<div
|
|
||||||
v-if="isOpen"
|
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
|
|
||||||
data-testid="lightbox"
|
|
||||||
@click.self="$emit('close')"
|
|
||||||
@keydown.esc="$emit('close')"
|
|
||||||
tabindex="0"
|
|
||||||
ref="lightboxRef"
|
|
||||||
>
|
|
||||||
<!-- Close Button -->
|
|
||||||
<button
|
|
||||||
class="absolute top-4 right-4 text-text text-3xl leading-none p-2 hover:text-accent transition-colors duration-fast"
|
|
||||||
data-testid="lightbox-close"
|
|
||||||
aria-label="Schließen"
|
|
||||||
@click="$emit('close')"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Prev Button -->
|
|
||||||
<button
|
|
||||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-text text-3xl leading-none p-3 hover:text-accent transition-colors duration-fast"
|
|
||||||
data-testid="lightbox-prev"
|
|
||||||
aria-label="Vorheriges Bild"
|
|
||||||
@click="$emit('prev')"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Image -->
|
|
||||||
<div class="max-w-4xl max-h-[80vh] px-16">
|
|
||||||
<img
|
|
||||||
:src="image.src"
|
|
||||||
:alt="image.alt"
|
|
||||||
class="max-w-full max-h-[80vh] object-contain rounded-lg"
|
|
||||||
/>
|
|
||||||
<p class="text-center text-text-muted text-sm mt-4">{{ image.caption }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Next Button -->
|
|
||||||
<button
|
|
||||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-text text-3xl leading-none p-3 hover:text-accent transition-colors duration-fast"
|
|
||||||
data-testid="lightbox-next"
|
|
||||||
aria-label="Nächstes Bild"
|
|
||||||
@click="$emit('next')"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch, onMounted, onUnmounted, nextTick } from "vue";
|
|
||||||
|
|
||||||
interface LightboxImage {
|
|
||||||
src: string;
|
|
||||||
alt: string;
|
|
||||||
caption: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
isOpen: boolean;
|
|
||||||
image: LightboxImage;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const lightboxRef = ref<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
close: [];
|
|
||||||
prev: [];
|
|
||||||
next: [];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const handleKeydown = (e: KeyboardEvent) => {
|
|
||||||
if (!props.isOpen) return;
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
emit("close");
|
|
||||||
} else if (e.key === "ArrowLeft") {
|
|
||||||
emit("prev");
|
|
||||||
} else if (e.key === "ArrowRight") {
|
|
||||||
emit("next");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener("keydown", handleKeydown);
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener("keydown", handleKeydown);
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.isOpen,
|
|
||||||
(open) => {
|
|
||||||
if (open) {
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
nextTick(() => {
|
|
||||||
lightboxRef.value?.focus();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="animate-pulse" :class="wrapperClass">
|
|
||||||
<div
|
|
||||||
v-for="n in count"
|
|
||||||
:key="n"
|
|
||||||
class="skeleton-shimmer rounded-md"
|
|
||||||
:style="{ height: height, marginBottom: gap }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
count?: number;
|
|
||||||
height?: string;
|
|
||||||
gap?: string;
|
|
||||||
wrapperClass?: string;
|
|
||||||
}>(), {
|
|
||||||
count: 3,
|
|
||||||
height: "20px",
|
|
||||||
gap: "12px",
|
|
||||||
wrapperClass: "w-full",
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center gap-2 p-4 rounded-lg border border-border-default bg-panel hover:border-accent-border transition-colors duration-fast cursor-pointer group"
|
|
||||||
role="button"
|
|
||||||
:tabindex="0"
|
|
||||||
:aria-label="type"
|
|
||||||
@click="$emit('select', type)"
|
|
||||||
@keydown.enter="$emit('select', type)"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
:width="size"
|
|
||||||
:height="size"
|
|
||||||
viewBox="0 0 48 48"
|
|
||||||
fill="none"
|
|
||||||
class="text-secondary group-hover:text-accent transition-colors duration-fast"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<!-- Line Array -->
|
|
||||||
<template v-if="type === 'line-array'">
|
|
||||||
<rect x="6" y="16" width="8" height="16" rx="1" fill="currentColor" />
|
|
||||||
<path d="M16 20 L24 14 M16 28 L24 34" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
||||||
<path d="M26 12 L30 10 M26 36 L30 38" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.6" />
|
|
||||||
<path d="M32 8 L36 6 M32 40 L36 42" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.3" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Subwoofer -->
|
|
||||||
<template v-else-if="type === 'subwoofer'">
|
|
||||||
<rect x="8" y="8" width="32" height="32" rx="2" fill="none" stroke="currentColor" stroke-width="2" />
|
|
||||||
<circle cx="24" cy="24" r="8" fill="none" stroke="currentColor" stroke-width="2" />
|
|
||||||
<circle cx="24" cy="24" r="4" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Point Source -->
|
|
||||||
<template v-else-if="type === 'point-source'">
|
|
||||||
<rect x="10" y="18" width="10" height="12" rx="1" fill="currentColor" />
|
|
||||||
<path d="M22 16 Q28 24 22 32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
||||||
<path d="M26 12 Q34 24 26 36" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.6" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Column Array -->
|
|
||||||
<template v-else-if="type === 'column-array'">
|
|
||||||
<rect x="16" y="6" width="16" height="36" rx="1" fill="none" stroke="currentColor" stroke-width="2" />
|
|
||||||
<circle cx="24" cy="14" r="3" fill="currentColor" />
|
|
||||||
<circle cx="24" cy="24" r="3" fill="currentColor" />
|
|
||||||
<circle cx="24" cy="34" r="3" fill="currentColor" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Monitor -->
|
|
||||||
<template v-else-if="type === 'monitor'">
|
|
||||||
<path d="M6 30 L24 14 L42 30 L42 34 L6 34 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
|
||||||
<circle cx="24" cy="26" r="4" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Moving Head -->
|
|
||||||
<template v-else>
|
|
||||||
<rect x="18" y="6" width="12" height="10" rx="1" fill="currentColor" />
|
|
||||||
<path d="M24 16 L24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
||||||
<circle cx="24" cy="30" r="6" fill="none" stroke="currentColor" stroke-width="2" />
|
|
||||||
<path d="M18 30 L14 30 M30 30 L34 30" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.5" />
|
|
||||||
</template>
|
|
||||||
</svg>
|
|
||||||
<span class="text-xs text-text-muted group-hover:text-accent transition-colors duration-fast">{{ label }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
type: "line-array" | "subwoofer" | "point-source" | "column-array" | "monitor" | "moving-head";
|
|
||||||
label?: string;
|
|
||||||
size?: number;
|
|
||||||
}>(), {
|
|
||||||
label: "",
|
|
||||||
size: 48,
|
|
||||||
});
|
|
||||||
|
|
||||||
const labelMap: Record<string, string> = {
|
|
||||||
"line-array": "Line Array",
|
|
||||||
"subwoofer": "Subwoofer",
|
|
||||||
"point-source": "Point Source",
|
|
||||||
"column-array": "Column Array",
|
|
||||||
"monitor": "Monitor",
|
|
||||||
"moving-head": "Moving Head",
|
|
||||||
};
|
|
||||||
|
|
||||||
const label = computed(() => props.label || labelMap[props.type] || props.type);
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
select: [type: string];
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen bg-bg text-text flex items-center justify-center px-4">
|
|
||||||
<div class="max-w-md text-center">
|
|
||||||
<p class="text-6xl font-extrabold text-accent mb-4">{{ error.statusCode }}</p>
|
|
||||||
<h1 class="text-2xl font-bold mb-3">
|
|
||||||
{{ error.statusCode === 404 ? 'Seite nicht gefunden' : 'Ein Fehler ist aufgetreten' }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-text-muted mb-8">
|
|
||||||
{{ error.statusCode === 404
|
|
||||||
? 'Die angeforderte Seite existiert nicht. Vielleicht wurde sie verschoben oder gelöscht.'
|
|
||||||
: 'Es ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es später erneut.'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<NuxtLink to="/" class="btn-primary">
|
|
||||||
Zurück zur Startseite
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { NuxtError } from "#app";
|
|
||||||
|
|
||||||
const props = defineProps<{ error: NuxtError }>();
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: `${props.error.statusCode} – HMS Licht & Ton`,
|
|
||||||
meta: [
|
|
||||||
{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
|
||||||
|
<meta name="theme-color" content="#131313" />
|
||||||
|
<meta name="description" content="HMS Licht & Ton – Professionelle Veranstaltungstechnik aus Leipheim/Ellzee. Vermietung, Verkauf, Personal, Transport von Tontechnik und Lichttechnik." />
|
||||||
|
<meta name="author" content="Hammerschmidt u. Mössle GbR" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" />
|
||||||
|
<title>HMS Licht & Ton – Veranstaltungstechnik</title>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "LocalBusiness",
|
||||||
|
"name": "HMS Licht & Ton – Hammerschmidt u. Mössle GbR",
|
||||||
|
"description": "Professionelle Veranstaltungstechnik aus Leipheim/Ellzee. Vermietung, Verkauf, Personal, Transport.",
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"streetAddress": "Grockelhofen 10",
|
||||||
|
"addressLocality": "Leipheim",
|
||||||
|
"postalCode": "89340",
|
||||||
|
"addressCountry": "DE"
|
||||||
|
},
|
||||||
|
"telephone": "+498221204433",
|
||||||
|
"email": "info@hms-licht-ton.de",
|
||||||
|
"url": "https://hms.media-on.de",
|
||||||
|
"areaServed": "Süddeutschland",
|
||||||
|
"knowsAbout": ["Tontechnik", "Lichttechnik", "Rigging", "Veranstaltungstechnik"]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
server {
|
||||||
|
listen 3000;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
location /mcp {
|
||||||
|
proxy_pass http://backend:8000/mcp;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-22
@@ -1,34 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "hms-licht-ton-frontend",
|
"name": "hms-licht-ton-frontend-react",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt build",
|
"dev": "vite",
|
||||||
"dev": "nuxt dev",
|
"build": "tsc && vite build",
|
||||||
"generate": "nuxt generate",
|
"preview": "vite preview",
|
||||||
"preview": "nuxt preview",
|
"typecheck": "tsc --noEmit"
|
||||||
"postinstall": "nuxt prepare",
|
|
||||||
"typecheck": "nuxt typecheck",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"test": "vitest run --reporter verbose",
|
|
||||||
"test:e2e": "playwright test"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"react": "^18.3.0",
|
||||||
"nuxt": "^3.14.0",
|
"react-dom": "^18.3.0",
|
||||||
"pinia": "^3.0.4",
|
"react-router-dom": "^6.26.0"
|
||||||
"vue": "^3.5.0",
|
|
||||||
"vue-router": "^4.4.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/test-utils": "^3.14.0",
|
"@types/react": "^18.3.0",
|
||||||
"@nuxtjs/tailwindcss": "^6.12.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@playwright/test": "^1.48.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"@vue/test-utils": "^2.4.5",
|
"autoprefixer": "^10.4.0",
|
||||||
"happy-dom": "^15.0.0",
|
"postcss": "^8.4.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vitest": "^3.2.0",
|
"vite": "^5.4.0"
|
||||||
"vue-tsc": "^2.1.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user