diff --git a/backend/Dockerfile b/backend/Dockerfile index 725f4be..d91fd2c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,9 +2,9 @@ FROM python:3.12-slim 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 \ - gcc libpq-dev && \ + gcc libpq-dev git docker.io curl && \ rm -rf /var/lib/apt/lists/* COPY requirements.txt . diff --git a/backend/app/main.py b/backend/app/main.py index b7be580..fcc9e4d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,7 @@ from app.config import get_settings from app.database import engine, init_db, async_session from app.cache import cache from app.routers import equipment, health, admin, rental_requests, contact +from app.mcp_server import get_mcp_app, mcp_session_manager from app.services.sync_service import SyncService from app.services.email_service import EmailService @@ -16,6 +17,7 @@ logger = logging.getLogger(__name__) settings = get_settings() scheduler = AsyncIOScheduler() +_mcp_lifespan_cm = None # MCP session manager context manager async def run_equipment_sync() -> None: @@ -38,6 +40,7 @@ async def run_email_retry() -> None: @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan: init DB, start scheduler, connect cache.""" + global _mcp_lifespan_cm # Create tables (for dev/testing; production uses Alembic) await init_db() @@ -62,9 +65,19 @@ async def lifespan(app: FastAPI): scheduler.start() logger.info("APScheduler started with equipment_sync (every 6h) and email_retry (every 15min)") + # Start MCP session manager (required for streamable HTTP transport) + # run() can only be called once, so store the context manager + _mcp_lifespan_cm = mcp_session_manager.run() + await _mcp_lifespan_cm.__aenter__() + logger.info("MCP session manager started") + yield # Shutdown + # Stop MCP session manager + if _mcp_lifespan_cm: + await _mcp_lifespan_cm.__aexit__(None, None, None) + _mcp_lifespan_cm = None scheduler.shutdown(wait=False) await cache.disconnect() await engine.dispose() @@ -92,3 +105,6 @@ app.include_router(health.router) app.include_router(admin.router) app.include_router(rental_requests.router) app.include_router(contact.router) + +# MCP Server (streamable HTTP at /mcp) +app.mount("/mcp", get_mcp_app()) diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py new file mode 100644 index 0000000..5dc8fbc --- /dev/null +++ b/backend/app/mcp_server.py @@ -0,0 +1,569 @@ +"""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 +try: + from mcp.server.fastmcp.settings import TransportSecuritySettings + _ts = TransportSecuritySettings(enable_dns_rebinding_protection=False) +except Exception: + _ts = None + +_mcp_kwargs: dict[str, Any] = { + "streamable_http_path": "/", + "stateless_http": True, +} +if _ts is not None: + _mcp_kwargs["transport_security"] = _ts + +mcp = FastMCP("hms-cms", **_mcp_kwargs) + +# ---------------------------------------------------------------------------# +# Bearer token authentication wrapper +# ---------------------------------------------------------------------------# +_MCP_TOKEN = os.environ.get("MCP_AUTH_TOKEN", "") + + +class MCPAuthMiddleware: + """ASGI middleware: validate Authorization: Bearer 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) diff --git a/backend/requirements.txt b/backend/requirements.txt index 75490a5..f1ee8d1 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,3 +16,4 @@ pytest>=8.0.0 pytest-asyncio>=0.23.0 pytest-cov>=5.0.0 httpx>=0.27.0 +mcp>=1.0.0 diff --git a/docker-compose.yml b/docker-compose.yml index 40aedeb..74a9c47 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,36 +2,35 @@ services: frontend: build: ./frontend ports: - - "3000:3000" + - 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" + - 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))"] + test: + - CMD-SHELL + - curl -sf http://localhost:3000/ || exit 1 interval: 30s timeout: 10s retries: 3 start_period: 15s networks: - - hms-network - - coolify - + - hms-network + - coolify backend: build: ./backend depends_on: @@ -40,68 +39,88 @@ services: 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} + - 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} + - MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN:-} volumes: - - ./images:/data/images + - ./:/data/repo + - ./images:/data/images + - /var/run/docker.sock:/var/run/docker.sock restart: unless-stopped healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + 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 - + - hms-network + - coolify + labels: + - traefik.enable=true + - traefik.http.routers.hms-mcp-http.entryPoints=http + - traefik.http.routers.hms-mcp-http.rule=Host(`hms.media-on.de`) && PathPrefix(`/mcp`) + - traefik.http.routers.hms-mcp-http.middlewares=hms-redirect + - traefik.http.routers.hms-mcp-https.entryPoints=https + - traefik.http.routers.hms-mcp-https.rule=Host(`hms.media-on.de`) && PathPrefix(`/mcp`) + - traefik.http.routers.hms-mcp-https.tls=true + - traefik.http.routers.hms-mcp-https.tls.certresolver=letsencrypt + - traefik.http.routers.hms-mcp-https.middlewares=hms-gzip + - traefik.http.services.hms-mcp.loadbalancer.server.port=8000 postgres: image: postgres:16-alpine volumes: - - postgres_data:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql/data environment: - - POSTGRES_USER=hms - - POSTGRES_PASSWORD=hms - - POSTGRES_DB=hms + - POSTGRES_USER=hms + - POSTGRES_PASSWORD=hms + - POSTGRES_DB=hms restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "pg_isready -U hms -d hms"] + test: + - CMD-SHELL + - pg_isready -U hms -d hms interval: 10s timeout: 5s retries: 5 start_period: 5s networks: - - hms-network - + - hms-network redis: image: redis:7-alpine volumes: - - redis_data:/data + - redis_data:/data restart: unless-stopped healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: + - CMD + - redis-cli + - ping interval: 10s timeout: 5s retries: 5 start_period: 5s networks: - - hms-network - + - hms-network networks: hms-network: driver: bridge coolify: external: true - volumes: - postgres_data: - redis_data: + postgres_data: null + redis_data: null diff --git a/docker-compose.yml.bak b/docker-compose.yml.bak new file mode 100644 index 0000000..40aedeb --- /dev/null +++ b/docker-compose.yml.bak @@ -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: diff --git a/frontend-nuxt-old/.dockerignore b/frontend-nuxt-old/.dockerignore new file mode 100644 index 0000000..0b6c734 --- /dev/null +++ b/frontend-nuxt-old/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.nuxt +.output +dist +.git +.gitignore +*.md +.env +.env.* +playwright-report +test-results +coverage diff --git a/frontend/.gitignore b/frontend-nuxt-old/.gitignore similarity index 100% rename from frontend/.gitignore rename to frontend-nuxt-old/.gitignore diff --git a/frontend-nuxt-old/Dockerfile b/frontend-nuxt-old/Dockerfile new file mode 100644 index 0000000..976dde8 --- /dev/null +++ b/frontend-nuxt-old/Dockerfile @@ -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"] diff --git a/frontend/app.vue b/frontend-nuxt-old/app.vue similarity index 100% rename from frontend/app.vue rename to frontend-nuxt-old/app.vue diff --git a/frontend/assets/css/main.css b/frontend-nuxt-old/assets/css/main.css similarity index 100% rename from frontend/assets/css/main.css rename to frontend-nuxt-old/assets/css/main.css diff --git a/frontend/assets/css/prototype-style.css b/frontend-nuxt-old/assets/css/prototype-style.css similarity index 100% rename from frontend/assets/css/prototype-style.css rename to frontend-nuxt-old/assets/css/prototype-style.css diff --git a/frontend/assets/logo.png b/frontend-nuxt-old/assets/logo.png similarity index 100% rename from frontend/assets/logo.png rename to frontend-nuxt-old/assets/logo.png diff --git a/frontend/assets/logo.svg b/frontend-nuxt-old/assets/logo.svg similarity index 100% rename from frontend/assets/logo.svg rename to frontend-nuxt-old/assets/logo.svg diff --git a/frontend/components/AppFooter.vue b/frontend-nuxt-old/components/AppFooter.vue similarity index 100% rename from frontend/components/AppFooter.vue rename to frontend-nuxt-old/components/AppFooter.vue diff --git a/frontend/components/AppHeader.vue b/frontend-nuxt-old/components/AppHeader.vue similarity index 100% rename from frontend/components/AppHeader.vue rename to frontend-nuxt-old/components/AppHeader.vue diff --git a/frontend/components/EmptyState.vue b/frontend-nuxt-old/components/EmptyState.vue similarity index 100% rename from frontend/components/EmptyState.vue rename to frontend-nuxt-old/components/EmptyState.vue diff --git a/frontend/components/EquipmentCard.vue b/frontend-nuxt-old/components/EquipmentCard.vue similarity index 100% rename from frontend/components/EquipmentCard.vue rename to frontend-nuxt-old/components/EquipmentCard.vue diff --git a/frontend/components/ErrorState.vue b/frontend-nuxt-old/components/ErrorState.vue similarity index 100% rename from frontend/components/ErrorState.vue rename to frontend-nuxt-old/components/ErrorState.vue diff --git a/frontend/components/HmsLogo.vue b/frontend-nuxt-old/components/HmsLogo.vue similarity index 100% rename from frontend/components/HmsLogo.vue rename to frontend-nuxt-old/components/HmsLogo.vue diff --git a/frontend/components/LegalContentPage.vue b/frontend-nuxt-old/components/LegalContentPage.vue similarity index 100% rename from frontend/components/LegalContentPage.vue rename to frontend-nuxt-old/components/LegalContentPage.vue diff --git a/frontend/components/LoadingSkeleton.vue b/frontend-nuxt-old/components/LoadingSkeleton.vue similarity index 100% rename from frontend/components/LoadingSkeleton.vue rename to frontend-nuxt-old/components/LoadingSkeleton.vue diff --git a/frontend/components/ServiceCard.vue b/frontend-nuxt-old/components/ServiceCard.vue similarity index 100% rename from frontend/components/ServiceCard.vue rename to frontend-nuxt-old/components/ServiceCard.vue diff --git a/frontend/components/SpeakerIcon.vue b/frontend-nuxt-old/components/SpeakerIcon.vue similarity index 100% rename from frontend/components/SpeakerIcon.vue rename to frontend-nuxt-old/components/SpeakerIcon.vue diff --git a/frontend/composables/useApi.ts b/frontend-nuxt-old/composables/useApi.ts similarity index 100% rename from frontend/composables/useApi.ts rename to frontend-nuxt-old/composables/useApi.ts diff --git a/frontend/composables/useCart.ts b/frontend-nuxt-old/composables/useCart.ts similarity index 100% rename from frontend/composables/useCart.ts rename to frontend-nuxt-old/composables/useCart.ts diff --git a/frontend/composables/useEquipment.ts b/frontend-nuxt-old/composables/useEquipment.ts similarity index 100% rename from frontend/composables/useEquipment.ts rename to frontend-nuxt-old/composables/useEquipment.ts diff --git a/frontend/error.vue b/frontend-nuxt-old/error.vue similarity index 100% rename from frontend/error.vue rename to frontend-nuxt-old/error.vue diff --git a/frontend/layouts/default.vue b/frontend-nuxt-old/layouts/default.vue similarity index 100% rename from frontend/layouts/default.vue rename to frontend-nuxt-old/layouts/default.vue diff --git a/frontend/nuxt.config.ts b/frontend-nuxt-old/nuxt.config.ts similarity index 97% rename from frontend/nuxt.config.ts rename to frontend-nuxt-old/nuxt.config.ts index 459ac97..a95031a 100644 --- a/frontend/nuxt.config.ts +++ b/frontend-nuxt-old/nuxt.config.ts @@ -3,6 +3,7 @@ import type { NuxtConfig } from "nuxt/schema"; export default defineNuxtConfig({ devtools: { enabled: false }, + ssr: false, modules: ["@nuxtjs/tailwindcss", "@pinia/nuxt"], css: ["~/assets/css/main.css"], app: { @@ -59,6 +60,7 @@ export default defineNuxtConfig({ }, }, routeRules: { + "/": { ssr: false }, "/mietkatalog": { ssr: true }, "/mietkatalog/**": { ssr: true }, "/warenkorb": { ssr: false }, @@ -76,6 +78,7 @@ export default defineNuxtConfig({ nitro: { compressPublicAssets: true, routeRules: { + "/": { ssr: false }, "/api/**": { proxy: "http://backend:8000/api/**", }, diff --git a/frontend/package-lock.json b/frontend-nuxt-old/package-lock.json similarity index 100% rename from frontend/package-lock.json rename to frontend-nuxt-old/package-lock.json diff --git a/frontend-nuxt-old/package.json b/frontend-nuxt-old/package.json new file mode 100644 index 0000000..14e1962 --- /dev/null +++ b/frontend-nuxt-old/package.json @@ -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" + } +} diff --git a/frontend/pages/admin.vue b/frontend-nuxt-old/pages/admin.vue similarity index 100% rename from frontend/pages/admin.vue rename to frontend-nuxt-old/pages/admin.vue diff --git a/frontend/pages/agb-vermietung.vue b/frontend-nuxt-old/pages/agb-vermietung.vue similarity index 100% rename from frontend/pages/agb-vermietung.vue rename to frontend-nuxt-old/pages/agb-vermietung.vue diff --git a/frontend/pages/datenschutz.vue b/frontend-nuxt-old/pages/datenschutz.vue similarity index 100% rename from frontend/pages/datenschutz.vue rename to frontend-nuxt-old/pages/datenschutz.vue diff --git a/frontend/pages/impressum.vue b/frontend-nuxt-old/pages/impressum.vue similarity index 100% rename from frontend/pages/impressum.vue rename to frontend-nuxt-old/pages/impressum.vue diff --git a/frontend-nuxt-old/pages/index.vue b/frontend-nuxt-old/pages/index.vue new file mode 100644 index 0000000..a8624ee --- /dev/null +++ b/frontend-nuxt-old/pages/index.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/pages/kontakt.vue b/frontend-nuxt-old/pages/kontakt.vue similarity index 100% rename from frontend/pages/kontakt.vue rename to frontend-nuxt-old/pages/kontakt.vue diff --git a/frontend/pages/mietkatalog.vue b/frontend-nuxt-old/pages/mietkatalog.vue similarity index 100% rename from frontend/pages/mietkatalog.vue rename to frontend-nuxt-old/pages/mietkatalog.vue diff --git a/frontend/pages/mietkatalog/[id].vue b/frontend-nuxt-old/pages/mietkatalog/[id].vue similarity index 100% rename from frontend/pages/mietkatalog/[id].vue rename to frontend-nuxt-old/pages/mietkatalog/[id].vue diff --git a/frontend/pages/referenzen.vue b/frontend-nuxt-old/pages/referenzen.vue similarity index 100% rename from frontend/pages/referenzen.vue rename to frontend-nuxt-old/pages/referenzen.vue diff --git a/frontend/pages/warenkorb.vue b/frontend-nuxt-old/pages/warenkorb.vue similarity index 100% rename from frontend/pages/warenkorb.vue rename to frontend-nuxt-old/pages/warenkorb.vue diff --git a/frontend/playwright.config.ts b/frontend-nuxt-old/playwright.config.ts similarity index 100% rename from frontend/playwright.config.ts rename to frontend-nuxt-old/playwright.config.ts diff --git a/frontend/plugins/pinia-persist.client.ts b/frontend-nuxt-old/plugins/pinia-persist.client.ts similarity index 100% rename from frontend/plugins/pinia-persist.client.ts rename to frontend-nuxt-old/plugins/pinia-persist.client.ts diff --git a/frontend/public/logo.svg b/frontend-nuxt-old/public/logo.svg similarity index 100% rename from frontend/public/logo.svg rename to frontend-nuxt-old/public/logo.svg diff --git a/frontend/server/routes/robots.txt.ts b/frontend-nuxt-old/server/routes/robots.txt.ts similarity index 100% rename from frontend/server/routes/robots.txt.ts rename to frontend-nuxt-old/server/routes/robots.txt.ts diff --git a/frontend/stores/cart.ts b/frontend-nuxt-old/stores/cart.ts similarity index 100% rename from frontend/stores/cart.ts rename to frontend-nuxt-old/stores/cart.ts diff --git a/frontend-nuxt-old/tailwind.config.ts b/frontend-nuxt-old/tailwind.config.ts new file mode 100644 index 0000000..ca7cb5f --- /dev/null +++ b/frontend-nuxt-old/tailwind.config.ts @@ -0,0 +1,65 @@ +import type { Config } from "tailwindcss"; + +export default { + 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: [], +}; diff --git a/frontend/test_report.md b/frontend-nuxt-old/test_report.md similarity index 100% rename from frontend/test_report.md rename to frontend-nuxt-old/test_report.md diff --git a/frontend/tests/e2e/cart.spec.ts b/frontend-nuxt-old/tests/e2e/cart.spec.ts similarity index 100% rename from frontend/tests/e2e/cart.spec.ts rename to frontend-nuxt-old/tests/e2e/cart.spec.ts diff --git a/frontend/tests/e2e/equipment-detail.spec.ts b/frontend-nuxt-old/tests/e2e/equipment-detail.spec.ts similarity index 100% rename from frontend/tests/e2e/equipment-detail.spec.ts rename to frontend-nuxt-old/tests/e2e/equipment-detail.spec.ts diff --git a/frontend/tests/e2e/error-pages.spec.ts b/frontend-nuxt-old/tests/e2e/error-pages.spec.ts similarity index 100% rename from frontend/tests/e2e/error-pages.spec.ts rename to frontend-nuxt-old/tests/e2e/error-pages.spec.ts diff --git a/frontend/tests/e2e/header-footer.spec.ts b/frontend-nuxt-old/tests/e2e/header-footer.spec.ts similarity index 100% rename from frontend/tests/e2e/header-footer.spec.ts rename to frontend-nuxt-old/tests/e2e/header-footer.spec.ts diff --git a/frontend/tests/e2e/home.spec.ts b/frontend-nuxt-old/tests/e2e/home.spec.ts similarity index 100% rename from frontend/tests/e2e/home.spec.ts rename to frontend-nuxt-old/tests/e2e/home.spec.ts diff --git a/frontend/tests/e2e/kontakt.spec.ts b/frontend-nuxt-old/tests/e2e/kontakt.spec.ts similarity index 100% rename from frontend/tests/e2e/kontakt.spec.ts rename to frontend-nuxt-old/tests/e2e/kontakt.spec.ts diff --git a/frontend/tests/e2e/legal-pages.spec.ts b/frontend-nuxt-old/tests/e2e/legal-pages.spec.ts similarity index 100% rename from frontend/tests/e2e/legal-pages.spec.ts rename to frontend-nuxt-old/tests/e2e/legal-pages.spec.ts diff --git a/frontend/tests/e2e/meta-robots.spec.ts b/frontend-nuxt-old/tests/e2e/meta-robots.spec.ts similarity index 100% rename from frontend/tests/e2e/meta-robots.spec.ts rename to frontend-nuxt-old/tests/e2e/meta-robots.spec.ts diff --git a/frontend/tests/e2e/mietanfrage.spec.ts b/frontend-nuxt-old/tests/e2e/mietanfrage.spec.ts similarity index 100% rename from frontend/tests/e2e/mietanfrage.spec.ts rename to frontend-nuxt-old/tests/e2e/mietanfrage.spec.ts diff --git a/frontend/tests/e2e/mietkatalog.spec.ts b/frontend-nuxt-old/tests/e2e/mietkatalog.spec.ts similarity index 100% rename from frontend/tests/e2e/mietkatalog.spec.ts rename to frontend-nuxt-old/tests/e2e/mietkatalog.spec.ts diff --git a/frontend/tests/e2e/referenzen.spec.ts b/frontend-nuxt-old/tests/e2e/referenzen.spec.ts similarity index 100% rename from frontend/tests/e2e/referenzen.spec.ts rename to frontend-nuxt-old/tests/e2e/referenzen.spec.ts diff --git a/frontend/tests/unit/CartStore.test.ts b/frontend-nuxt-old/tests/unit/CartStore.test.ts similarity index 100% rename from frontend/tests/unit/CartStore.test.ts rename to frontend-nuxt-old/tests/unit/CartStore.test.ts diff --git a/frontend/tests/unit/DesignTokens.test.ts b/frontend-nuxt-old/tests/unit/DesignTokens.test.ts similarity index 100% rename from frontend/tests/unit/DesignTokens.test.ts rename to frontend-nuxt-old/tests/unit/DesignTokens.test.ts diff --git a/frontend/tests/unit/EquipmentDetailPage.test.ts b/frontend-nuxt-old/tests/unit/EquipmentDetailPage.test.ts similarity index 100% rename from frontend/tests/unit/EquipmentDetailPage.test.ts rename to frontend-nuxt-old/tests/unit/EquipmentDetailPage.test.ts diff --git a/frontend/tests/unit/HomePage.test.ts b/frontend-nuxt-old/tests/unit/HomePage.test.ts similarity index 100% rename from frontend/tests/unit/HomePage.test.ts rename to frontend-nuxt-old/tests/unit/HomePage.test.ts diff --git a/frontend/tests/unit/KontaktPage.test.ts b/frontend-nuxt-old/tests/unit/KontaktPage.test.ts similarity index 100% rename from frontend/tests/unit/KontaktPage.test.ts rename to frontend-nuxt-old/tests/unit/KontaktPage.test.ts diff --git a/frontend/tests/unit/Layout.test.ts b/frontend-nuxt-old/tests/unit/Layout.test.ts similarity index 100% rename from frontend/tests/unit/Layout.test.ts rename to frontend-nuxt-old/tests/unit/Layout.test.ts diff --git a/frontend/tests/unit/MietkatalogPage.test.ts b/frontend-nuxt-old/tests/unit/MietkatalogPage.test.ts similarity index 100% rename from frontend/tests/unit/MietkatalogPage.test.ts rename to frontend-nuxt-old/tests/unit/MietkatalogPage.test.ts diff --git a/frontend/tests/unit/ReferenzenPage.test.ts b/frontend-nuxt-old/tests/unit/ReferenzenPage.test.ts similarity index 100% rename from frontend/tests/unit/ReferenzenPage.test.ts rename to frontend-nuxt-old/tests/unit/ReferenzenPage.test.ts diff --git a/frontend/tests/unit/WarenkorbPage.test.ts b/frontend-nuxt-old/tests/unit/WarenkorbPage.test.ts similarity index 100% rename from frontend/tests/unit/WarenkorbPage.test.ts rename to frontend-nuxt-old/tests/unit/WarenkorbPage.test.ts diff --git a/frontend/tests/unit/useEquipment.test.ts b/frontend-nuxt-old/tests/unit/useEquipment.test.ts similarity index 100% rename from frontend/tests/unit/useEquipment.test.ts rename to frontend-nuxt-old/tests/unit/useEquipment.test.ts diff --git a/frontend-nuxt-old/tsconfig.json b/frontend-nuxt-old/tsconfig.json new file mode 100644 index 0000000..4b34df1 --- /dev/null +++ b/frontend-nuxt-old/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/frontend/vitest.config.ts b/frontend-nuxt-old/vitest.config.ts similarity index 100% rename from frontend/vitest.config.ts rename to frontend-nuxt-old/vitest.config.ts diff --git a/frontend/.dockerignore b/frontend/.dockerignore index 0b6c734..a21f178 100644 --- a/frontend/.dockerignore +++ b/frontend/.dockerignore @@ -1,12 +1,3 @@ node_modules -.nuxt -.output dist .git -.gitignore -*.md -.env -.env.* -playwright-report -test-results -coverage diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 976dde8..b5d82c5 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,21 +1,14 @@ -# --- Stage 1: Build --- -FROM node:20 AS builder - +# Stage 1: Build +FROM node:20-alpine AS builder WORKDIR /app - -COPY package.json package-lock.json ./ -RUN npm ci - +COPY package.json package-lock.json* ./ +RUN npm ci || npm install COPY . . RUN npm run build -# --- Stage 2: Production --- -FROM node:20-slim AS production - -WORKDIR /app - -COPY --from=builder /app/.output ./.output - +# Stage 2: Serve with Nginx +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 3000 - -CMD ["node", ".output/server/index.mjs"] +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..41295c9 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + HMS Licht & Ton – Veranstaltungstechnik + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..ab4654b --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 3000; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/frontend/package.json b/frontend/package.json index 14e1962..a167776 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,34 +1,26 @@ { - "name": "hms-licht-ton-frontend", + "name": "hms-licht-ton-frontend-react", "private": true, "type": "module", "scripts": { - "build": "nuxt build", - "dev": "nuxt dev", - "generate": "nuxt generate", - "preview": "nuxt preview", - "postinstall": "nuxt prepare", - "typecheck": "nuxt typecheck", - "lint": "eslint .", - "test": "vitest run --reporter verbose", - "test:e2e": "playwright test" + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" }, "dependencies": { - "@pinia/nuxt": "^0.11.3", - "nuxt": "^3.14.0", - "pinia": "^3.0.4", - "vue": "^3.5.0", - "vue-router": "^4.4.0" + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^6.26.0" }, "devDependencies": { - "@nuxt/test-utils": "^3.14.0", - "@nuxtjs/tailwindcss": "^6.12.0", - "@playwright/test": "^1.48.0", - "@vue/test-utils": "^2.4.5", - "happy-dom": "^15.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", "tailwindcss": "^3.4.0", "typescript": "^5.6.0", - "vitest": "^3.2.0", - "vue-tsc": "^2.1.0" + "vite": "^5.4.0" } } diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue deleted file mode 100644 index 74312e4..0000000 --- a/frontend/pages/index.vue +++ /dev/null @@ -1,121 +0,0 @@ - - - diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..640240b --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/img/ref1.jpg b/frontend/public/img/ref1.jpg new file mode 100644 index 0000000..e7cf764 Binary files /dev/null and b/frontend/public/img/ref1.jpg differ diff --git a/frontend/public/img/ref2.jpg b/frontend/public/img/ref2.jpg new file mode 100644 index 0000000..af0dac6 Binary files /dev/null and b/frontend/public/img/ref2.jpg differ diff --git a/frontend/public/img/ref3.jpg b/frontend/public/img/ref3.jpg new file mode 100644 index 0000000..cd2cd54 Binary files /dev/null and b/frontend/public/img/ref3.jpg differ diff --git a/frontend/public/img/ref4.jpg b/frontend/public/img/ref4.jpg new file mode 100644 index 0000000..a08b47f Binary files /dev/null and b/frontend/public/img/ref4.jpg differ diff --git a/frontend/public/img/ref5.jpg b/frontend/public/img/ref5.jpg new file mode 100644 index 0000000..012d70c Binary files /dev/null and b/frontend/public/img/ref5.jpg differ diff --git a/frontend/public/img/ref6.jpg b/frontend/public/img/ref6.jpg new file mode 100644 index 0000000..c6529db Binary files /dev/null and b/frontend/public/img/ref6.jpg differ diff --git a/frontend/public/img/ref7.jpg b/frontend/public/img/ref7.jpg new file mode 100644 index 0000000..d7c582f Binary files /dev/null and b/frontend/public/img/ref7.jpg differ diff --git a/frontend/public/img/ref8.jpg b/frontend/public/img/ref8.jpg new file mode 100644 index 0000000..b75e410 Binary files /dev/null and b/frontend/public/img/ref8.jpg differ diff --git a/frontend/public/img/ref9.jpg b/frontend/public/img/ref9.jpg new file mode 100644 index 0000000..3d58b77 Binary files /dev/null and b/frontend/public/img/ref9.jpg differ diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f8cd0d6 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,33 @@ +import { Routes, Route } from 'react-router-dom'; +import Layout from './components/Layout'; +import Index from './pages/Index'; +import Mietkatalog from './pages/Mietkatalog'; +import EquipmentDetail from './pages/EquipmentDetail'; +import Kontakt from './pages/Kontakt'; +import Warenkorb from './pages/Warenkorb'; +import Admin from './pages/Admin'; +import Referenzen from './pages/Referenzen'; +import Impressum from './pages/Impressum'; +import Datenschutz from './pages/Datenschutz'; +import AGB from './pages/AGB'; +import ErrorPage from './pages/ErrorPage'; + +export default function App() { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 0000000..3de14c3 --- /dev/null +++ b/frontend/src/api/admin.ts @@ -0,0 +1,9 @@ +import { apiPost } from './client'; + +export async function adminLogin(username: string, password: string): Promise<{ token: string }> { + return apiPost<{ token: string }>('/admin/login', { username, password }); +} + +export async function adminSync(token: string): Promise<{ status: string; message: string }> { + return apiPost<{ status: string; message: string }>('/admin/sync', undefined, token); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..d455dcd --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,54 @@ +const API_BASE = '/api'; + +export async function apiGet(url: string, params?: Record): Promise { + const query = new URLSearchParams(); + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && value !== '') { + query.set(key, String(value)); + } + } + } + const qs = query.toString(); + const fullUrl = `${API_BASE}${url}${qs ? '?' + qs : ''}`; + const res = await fetch(fullUrl, { + headers: { 'Content-Type': 'application/json' }, + }); + if (!res.ok) throw new Error(`API Error: ${res.status}`); + return res.json(); +} + +export async function apiPost(url: string, body?: unknown, token?: string): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(`${API_BASE}${url}`, { + method: 'POST', + headers, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) throw new Error(`API Error: ${res.status}`); + return res.json(); +} + +export async function apiPut(url: string, body?: unknown, token?: string): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(`${API_BASE}${url}`, { + method: 'PUT', + headers, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) throw new Error(`API Error: ${res.status}`); + return res.json(); +} + +export async function apiDelete(url: string, token?: string): Promise { + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(`${API_BASE}${url}`, { + method: 'DELETE', + headers, + }); + if (!res.ok) throw new Error(`API Error: ${res.status}`); + return res.json(); +} diff --git a/frontend/src/api/equipment.ts b/frontend/src/api/equipment.ts new file mode 100644 index 0000000..4db04b7 --- /dev/null +++ b/frontend/src/api/equipment.ts @@ -0,0 +1,30 @@ +import { apiGet } from './client'; +import type { EquipmentItem, EquipmentDetail, PaginatedEquipment, SortOption } from '../types'; + +export async function fetchEquipment(params: { + search?: string; + category?: string; + sort?: SortOption; + page?: number; + page_size?: number; +}): Promise { + return apiGet('/equipment', { + search: params.search || undefined, + category: params.category || undefined, + sort: params.sort || 'name_asc', + page: params.page || 1, + page_size: params.page_size || 24, + }); +} + +export async function fetchEquipmentDetail(id: number | string): Promise { + return apiGet(`/equipment/${id}`); +} + +export async function fetchCategories(): Promise { + return apiGet('/equipment/categories'); +} + +export async function fetchEquipmentById(id: number | string): Promise { + return apiGet(`/equipment/${id}`); +} diff --git a/frontend/src/components/EmptyState.tsx b/frontend/src/components/EmptyState.tsx new file mode 100644 index 0000000..11a65ca --- /dev/null +++ b/frontend/src/components/EmptyState.tsx @@ -0,0 +1,18 @@ +interface Props { + icon?: string; + title: string; + message: string; + actionLabel?: string; + onAction?: () => void; +} + +export default function EmptyState({ icon = '🔍', title, message, actionLabel, onAction }: Props) { + return ( +
+ +

{title}

+

{message}

+ {actionLabel && } +
+ ); +} diff --git a/frontend/src/components/EquipmentCard.tsx b/frontend/src/components/EquipmentCard.tsx new file mode 100644 index 0000000..94f57ea --- /dev/null +++ b/frontend/src/components/EquipmentCard.tsx @@ -0,0 +1,30 @@ +import type { EquipmentItem } from '../types'; + +interface Props { + item: EquipmentItem & { code?: string; image?: string }; + onClick: (item: any) => void; + onAddToCart: (item: any) => void; +} + +export default function EquipmentCard({ item, onClick, onAddToCart }: Props) { + return ( +
onClick(item)} role="article" tabIndex={0} onKeyDown={e => e.key === 'Enter' && onClick(item)}> +
+ {item.image ? ( + {item.name} + ) : ( +
📦
+ )} + {item.category && {item.category}} +
+
+

{item.name}

+

{item.description}

+
+ {item.code} + +
+
+
+ ); +} diff --git a/frontend/src/components/ErrorState.tsx b/frontend/src/components/ErrorState.tsx new file mode 100644 index 0000000..9702862 --- /dev/null +++ b/frontend/src/components/ErrorState.tsx @@ -0,0 +1,18 @@ +interface Props { + title?: string; + message: string; + onRetry: () => void; +} + +export default function ErrorState({ title = 'Ein Fehler ist aufgetreten', message, onRetry }: Props) { + return ( +
+
+ +
+

{title}

+

{message}

+ +
+ ); +} diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx new file mode 100644 index 0000000..41ad8b7 --- /dev/null +++ b/frontend/src/components/Footer.tsx @@ -0,0 +1,54 @@ +import { Link } from 'react-router-dom'; +import Logo from './Logo'; + +export default function Footer() { + const year = new Date().getFullYear(); + return ( +
+
+
+
+
+ +
HMS Licht & Ton
Veranstaltungstechnik
+
+

Hammerschmidt u. Mössle GbR – seit über 20 Jahren Ihr Partner für professionelle Veranstaltungstechnik in der Region Leipheim / Ellzee.

+
+
+

Navigation

+
    +
  • Home
  • +
  • Referenzen
  • +
  • Mietkatalog
  • +
  • Kontakt
  • +
+
+
+

Kontakt

+ +
+
+

Rechtliches

+
    +
  • Impressum
  • +
  • DSGVO
  • +
  • AGB Vermietung
  • +
  • Admin-Login
  • +
+
+
+
+ © {year} Hammerschmidt u. Mössle GbR · Veranstaltungstechnik · Leipheim / Ellzee +
+
+
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..f90a7de --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import Logo from './Logo'; + +const navItems = [ + { label: 'Home', to: '/' }, + { label: 'Referenzen', to: '/referenzen' }, + { label: 'Mietkatalog', to: '/mietkatalog' }, + { label: 'Kontakt', to: '/kontakt' } +]; + +export default function Header() { + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const location = useLocation(); + + function isActive(to: string): boolean { + if (to === '/') return location.pathname === '/'; + return location.pathname.startsWith(to); + } + + return ( +
+
+
+ + +
+
HMS Licht & Ton
+
Veranstaltungstechnik
+
+ + + +
+
+ {mobileMenuOpen && ( + + )} +
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..321bc48 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,16 @@ +import { Outlet } from 'react-router-dom'; +import Header from './Header'; +import Footer from './Footer'; + +export default function Layout() { + return ( +
+ Zum Hauptinhalt springen +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/LegalContentPage.tsx b/frontend/src/components/LegalContentPage.tsx new file mode 100644 index 0000000..5088593 --- /dev/null +++ b/frontend/src/components/LegalContentPage.tsx @@ -0,0 +1,36 @@ +import { useEffect } from 'react'; + +interface Props { + title: string; + content?: string; + loading?: boolean; +} + +export default function LegalContentPage({ title, content = '', loading = false }: Props) { + useEffect(() => { + document.title = `${title} – HMS Licht & Ton`; + }, [title]); + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+ ); + } + + return ( +
+
+

{title}

+
+
+
+ ); +} diff --git a/frontend/src/components/LoadingSkeleton.tsx b/frontend/src/components/LoadingSkeleton.tsx new file mode 100644 index 0000000..a0e1fdf --- /dev/null +++ b/frontend/src/components/LoadingSkeleton.tsx @@ -0,0 +1,24 @@ +interface Props { + count?: number; +} + +export default function LoadingSkeleton({ count = 6 }: Props) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx new file mode 100644 index 0000000..357f5e0 --- /dev/null +++ b/frontend/src/components/Logo.tsx @@ -0,0 +1,12 @@ +interface LogoProps { + size?: number; +} + +export default function Logo({ size = 40 }: LogoProps) { + return ( + + + + + ); +} diff --git a/frontend/src/components/ServiceCard.tsx b/frontend/src/components/ServiceCard.tsx new file mode 100644 index 0000000..048716b --- /dev/null +++ b/frontend/src/components/ServiceCard.tsx @@ -0,0 +1,15 @@ +interface Props { + icon: string; + title: string; + description: string; +} + +export default function ServiceCard({ icon, title, description }: Props) { + return ( +
+
+

{title}

+

{description}

+
+ ); +} diff --git a/frontend/src/components/SpeakerIcon.tsx b/frontend/src/components/SpeakerIcon.tsx new file mode 100644 index 0000000..9c909b1 --- /dev/null +++ b/frontend/src/components/SpeakerIcon.tsx @@ -0,0 +1,22 @@ +import { useMemo } from 'react'; + +interface Props { + type?: string; +} + +const icons: Record = { + linearray: '', + subwoofer: '', + monitor: '', + pointsource: '', + column: '', + movinghead: '' +}; + +export default function SpeakerIcon({ type = 'linearray' }: Props) { + const svgStr = useMemo(() => + '', [type]); + return ; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..904b65e --- /dev/null +++ b/frontend/src/index.css @@ -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 */ +.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 */ +.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 */ +.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 */ +.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 */ +.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 */ +.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); } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..fe35402 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App'; +import './index.css'; +import { CartProvider } from './store/CartContext'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + +); diff --git a/frontend/src/pages/AGB.tsx b/frontend/src/pages/AGB.tsx new file mode 100644 index 0000000..e6573b6 --- /dev/null +++ b/frontend/src/pages/AGB.tsx @@ -0,0 +1,103 @@ +import LegalContentPage from '../components/LegalContentPage'; + +const content = ` +

§ 1 Geltungsbereich

+

+ Diese Allgemeinen Geschäftsbedingungen (AGB) gelten für alle Mietverträge über + Veranstaltungstechnik zwischen der Hammerschmidt u. Mössle GbR (nachfolgend „HMS + Licht & Ton“ oder „Vermieter“) und dem Mieter. Sie gelten ausschließlich; + entgegenstehende oder von unseren AGB abweichende Bedingungen des Mieters + erkennen wir nicht an, es sei denn, wir hätten ausdrücklich schriftlich ihrer + Geltung zugestimmt. +

+ +

§ 2 Mietgegenstand

+

+ Gegenstand des Mietvertrages ist die Überlassung von Veranstaltungstechnik + (Lautsprecher, Mischpulte, Lichtanlagen, Rigging-Material, Traversen, + Zubehör etc.) gemäß der im Mietvertrag bzw. in der Auftragsbestätigung + angegebenen Spezifikation. Der Vermieter schuldet nicht die Durchführung der + Veranstaltung, sondern nur die Überlassung der gemieteten Technik in + vertragsgemäßem Zustand. +

+ +

§ 3 Preise

+

+ Es gelten die zum Zeitpunkt der Auftragsbestätigung im Mietkatalog bzw. im + Angebot angegebenen Preise. Alle Preise verstehen sich zzgl. der gesetzlich + geltenden Mehrwertsteuer. Miettage berechnen sich ab Abholung bzw. Anlieferung + bis zur Rückgabe bzw. Abholung durch den Vermieter. Ein Miettag entspricht + 24 Stunden. Bei Überschreitung der vereinbarten Mietzeit werden zusätzliche + Miettage berechnet. +

+ +

§ 4 Zahlungsbedingungen

+

+ Die Zahlung ist innerhalb von 14 Tagen nach Rechnungsstellung ohne Abzug + fällig, sofern nichts anderes vereinbart wurde. Bei Neukunden kann eine + Anzahlung in Höhe von 50 % des Gesamtmietpreises vor Übergabe der Technik + verlangt werden. Die Aufrechnung mit Gegenansprüchen ist nur zulässig, + soweit diese unbestritten oder rechtskräftig festgestellt sind. +

+ +

§ 5 Haftung des Mieters

+

+ Der Mieter hat das Mietmaterial pfleglich zu behandeln und vor Beschädigung, + Diebstahl und unsachgemäßer Nutzung zu schützen. Der Mieter haftet für alle + Schäden am Mietmaterial, die während der Mietzeit entstehen, soweit sie nicht + auf normaler Abnutzung beruhen. Bei Verlust oder Diebstahl trägt der Mieter + den Wiederbeschaffungswert. Bei Beschädigung trägt der Mieter die + Reparaturkosten oder den Zeitwert bei Totalschaden. +

+

+ Eine Haftung des Vermieters für indirekte Schäden, insbesondere für + Gewinnverluste, Ausfallkosten oder Folgeschäden der Veranstaltung, ist + ausgeschlossen, soweit diese nicht auf Vorsatz oder grober Fahrlässigkeit + beruhen. +

+ +

§ 6 Übergabe und Rückgabe

+

+ Die Übergabe des Mietmaterials erfolgt am Lager des Vermieters oder an dem + vereinbarten Veranstaltungsort. Der Mieter hat das Mietmaterial bei + Übergabe auf Vollständigkeit und Schäden zu prüfen. Mängel sind sofort + schriftlich zu melden. Bei der Rückgabe wird das Mietmaterial erneut + geprüft. Fehlbestände oder Schäden, die erst bei der Rückgabe festgestellt + werden, gelten als während der Mietzeit entstanden, sofern der Mieter nicht + den Beweis erbringt, dass der Schaden bereits bei Übergabe vorlag. +

+

+ Das Mietmaterial ist nach Ende der Mietzeit in gereinigtem und + sortiertem Zustand zurückzugeben. Verpackungsmaterial ist vollständig + zurückzugeben. Nicht rechtzeitig zurückgegebenes Material wird bis zur + Rückgabe weiterberechnet. +

+ +

§ 7 Kündigung und Rücktritt

+

+ Der Mieter kann den Mietvertrag jederzeit vor Übergabe des Mietmaterials + schriftlich kündigen. Bei Kündigung bis 14 Tage vor Mietbeginn werden 25 %, + bis 7 Tage 50 %, bis 3 Tage 75 % und ab 3 Tagen 100 % des Mietpreises als + Stornokosten berechnet. +

+

+ Der Vermieter behält sich vor, vom Vertrag zurückzutreten, wenn die + Verfügung über das Mietmaterial aus Gründen, die der Vermieter nicht zu + vertreten hat, unmöglich wird (z. B. höhere Gewalt, höhere Gewalt, + unvorhersehbare Ereignisse). In diesem Fall werden bereits gezahlte + Mietkosten erstattet. +

+ +

§ 8 Schlussbestimmungen

+

+ Es gilt das Recht der Bundesrepublik Deutschland. Gerichtsstand ist + Günzburg. Sollten einzelne Bestimmungen dieser AGB unwirksam sein, so + bleibt die Wirksamkeit der übrigen Bestimmungen unberührt. An die Stelle + der unwirksamen Bestimmung tritt eine Regelung, die dem wirtschaftlichen + Zweck der unwirksamen Bestimmung am nächsten kommt. +

+`; + +export default function AGB() { + return ; +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx new file mode 100644 index 0000000..cd86c09 --- /dev/null +++ b/frontend/src/pages/Admin.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import Logo from '../components/Logo'; + +export default function Admin() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [loggedIn, setLoggedIn] = useState(false); + + function login(e: React.FormEvent) { + e.preventDefault(); + setError(''); + if (!username || !password) { + setError('Bitte Benutzername und Passwort eingeben'); + return; + } + setLoading(true); + setTimeout(() => { + setLoading(false); + setLoggedIn(true); + }, 1200); + } + + return ( +
+
+
+ +

Admin-Login

+

Equipment-Sync Verwaltungsbereich

+
+ {loggedIn ? ( +
+
+ +
+

Eingeloggt

+

(Prototyp – keine echte Session)

+
+ ) : ( +
+
+
+ + setUsername(e.target.value)} type="text" className="hms-input" placeholder="admin" aria-invalid={!!error} /> +
+
+ + setPassword(e.target.value)} type="password" className="hms-input" placeholder="••••••••" aria-invalid={!!error} /> +
+ {error &&

{error}

} + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/Datenschutz.tsx b/frontend/src/pages/Datenschutz.tsx new file mode 100644 index 0000000..54ce198 --- /dev/null +++ b/frontend/src/pages/Datenschutz.tsx @@ -0,0 +1,103 @@ +import LegalContentPage from '../components/LegalContentPage'; + +const content = ` +

1. Datenschutz auf einen Blick

+

+ Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren + personenbezogenen Daten passiert, wenn Sie diese Website besuchen oder unser + Kontaktformular nutzen. Personenbezogene Daten sind alle Daten, mit denen Sie + persönlich identifiziert werden können. +

+ +

Datenerhebung

+

+ Die Datenerhebung auf dieser Website erfolgt technisch notwendig zur Bereitstellung + der Website und zur Gewährleistung der Funktionstüchtigkeit und Sicherheit der + Systeme. Darüber hinaus werden Daten nur erhoben, wenn Sie uns diese im Rahmen + des Kontaktformulars freiwillig zur Verfügung stellen. +

+ +

Cookies

+

+ Diese Website verwendet keine Tracking-Cookies. Technisch notwendige Cookies, + die für den Betrieb der Seite erforderlich sind, werden nur verwendet, soweit + dies für die Bereitstellung der Funktionalität der Website zwingend notwendig + ist. Sie können die Speicherung solcher Cookies durch entsprechende Einstellungen + in Ihrem Browser verhindern. +

+ +

2. Verantwortliche Stelle

+

+ Verantwortlich für die Datenverarbeitung auf dieser Website ist:
+ Hammerschmidt u. Mössle GbR
+ Grockelhofen 10
+ 89340 Leipheim
+ Telefon: +49 (0) 8221 / 204433
+ E-Mail: info@hms-licht-ton.de +

+ +

3. Erhebung von Daten beim Kontaktformular

+

+ Wenn Sie uns über das Kontaktformular Anfragen zukommen lassen, werden Ihre + Angaben aus dem Anfrageformular inklusive der von Ihnen dort angegebenen + Kontaktdaten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen + bei uns gespeichert. Diese Daten geben wir nicht ohne Ihre Einwilligung weiter. +

+

+ Die Verarbeitung dieser Daten erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b + DSGVO, sofern Ihre Anfrage mit der Erfüllung eines Vertrags zusammenhängt oder + zur Durchführung vorvertraglicher Maßnahmen erforderlich ist. In allen + übrigen Fällen ist die Verarbeitung auf Grundlage Ihres berechtigten + Interesses (Art. 6 Abs. 1 lit. f DSGVO) oder auf Grundlage Ihrer Einwilligung + (Art. 6 Abs. 1 lit. a DSGVO) erfolgt. +

+

+ Die Daten werden gelöscht, sobald sie für die Erreichung des Zweckes ihrer + Erhebung nicht mehr erforderlich sind. Für die personenbezogenen Daten aus + dem Eingabeformular der Kontaktanfrage ist dies der Fall, wenn die jeweilige + Konversation mit Ihnen beendet ist. Der Gesprächsbeginn endet, wenn aus den + Umständen zu entnehmen ist, dass der betroffene Sachverhalt abschließend + geklärt ist. +

+ +

4. Server-Log-Dateien

+

+ Der Provider der Seiten erhebt und speichert automatisch Informationen in + sogenannten Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt. + Diese sind: +

+
    +
  • Browsertyp und Browserversion
  • +
  • verwendetes Betriebssystem
  • +
  • Referrer URL
  • +
  • Hostname des zugreifenden Rechners
  • +
  • Uhrzeit der Serveranfrage
  • +
  • IP-Adresse (anonymisiert)
  • +
+

+ Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht + vorgenommen. Die Verarbeitung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f + DSGVO zum Zweck der Gewährleistung eines reibungslosen Betriebs der Website. +

+ +

5. Weitergabe an Drittanbieter

+

+ Eine Übermittlung Ihrer persönlichen Daten an Dritte zu anderen als den im + Folgenden aufgeführten Zwecken findet nicht statt. Eine Weitergabe erfolgt nur, + wenn Sie eine ausdrückliche Einwilligung dazu erteilt haben oder die + Weitergabe gesetzlich zulässig ist. +

+ +

6. Ihre Rechte

+

+ Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der + Verarbeitung, Widerspruch gegen die Verarbeitung und das Recht auf + Datenübertragbarkeit (Art. 15–20 DSGVO). Zudem haben Sie das Recht, sich bei + einer Datenschutz-Aufsichtsbehörde über die Verarbeitung Ihrer + personenbezogenen Daten zu beschweren. +

+`; + +export default function Datenschutz() { + return ; +} diff --git a/frontend/src/pages/EquipmentDetail.tsx b/frontend/src/pages/EquipmentDetail.tsx new file mode 100644 index 0000000..61bfef5 --- /dev/null +++ b/frontend/src/pages/EquipmentDetail.tsx @@ -0,0 +1,132 @@ +import { useState, useEffect, useRef } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { fetchEquipmentDetail } from '../api/equipment'; +import type { EquipmentDetail } from '../types'; +import { useCart } from '../store/CartContext'; +import ErrorState from '../components/ErrorState'; + +export default function EquipmentDetail() { + const { id } = useParams(); + const navigate = useNavigate(); + const { addItemByFields } = useCart(); + const [item, setItem] = useState(null); + const [pending, setPending] = useState(true); + const [error, setError] = useState(false); + const [quantity, setQuantity] = useState(1); + const [rentalStart, setRentalStart] = useState(''); + const [rentalEnd, setRentalEnd] = useState(''); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + setPending(true); + setError(false); + if (id) { + fetchEquipmentDetail(id).then(data => { + if (mountedRef.current) { setItem(data); setPending(false); } + }).catch(() => { + if (mountedRef.current) { setError(true); setPending(false); } + }); + } + return () => { mountedRef.current = false; }; + }, [id]); + + function navigateTo(route: string) { + navigate(route); + window.scrollTo(0, 0); + } + + function addToCart() { + if (!item) return; + addItemByFields({ + equipment_id: item.id, + name: item.name, + rental_price: null, + image_url: item.image_url || null, + }); + navigateTo('/warenkorb'); + } + + return ( +
+ + {pending && ( +
+
+
+
+
+
+
+
+
+
+ )} + {error && navigateTo('/mietkatalog')} />} + {!pending && !error && item && ( +
+
+
+ {item.image_url ? ( + {item.name} + ) : ( +
+
📦
+
Kein Bild verfügbar
+
+ )} + {item.category && {item.category}} +
+
+
Artikelnummer: {item.number || item.rentman_id}
+

{item.name}

+ {item.brand &&
Marke: {item.brand}
} + {item.description &&

{item.description}

} + {item.specifications && ( +
+

Technische Daten

+
+ {Object.entries(item.specifications).map(([key, value]) => ( +
+
{key.replace(/_/g, ' ')}
+
{String(value)}
+
+ ))} +
+
+ )} +
+

Mietanfrage

+
+
+ + setRentalStart(e.target.value)} type="date" className="hms-input text-sm" /> +
+
+ + setRentalEnd(e.target.value)} type="date" className="hms-input text-sm" /> +
+
+
+ +
+ + setQuantity(Math.max(1, Number(e.target.value)))} type="number" min={1} className="hms-input text-center w-20" aria-label="Anzahl" /> + +
+
+ +

Preise auf Anfrage – unverbindliche Mietanfrage

+
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/ErrorPage.tsx b/frontend/src/pages/ErrorPage.tsx new file mode 100644 index 0000000..9fe85a3 --- /dev/null +++ b/frontend/src/pages/ErrorPage.tsx @@ -0,0 +1,20 @@ +import { Link } from 'react-router-dom'; +import Header from '../components/Header'; +import Footer from '../components/Footer'; + +export default function ErrorPage() { + return ( +
+
+
+
+
404
+

Seite nicht gefunden

+

Die angeforderte Seite existiert nicht.

+ Zur Startseite +
+
+
+
+ ); +} diff --git a/frontend/src/pages/Impressum.tsx b/frontend/src/pages/Impressum.tsx new file mode 100644 index 0000000..9b2484a --- /dev/null +++ b/frontend/src/pages/Impressum.tsx @@ -0,0 +1,74 @@ +import LegalContentPage from '../components/LegalContentPage'; + +const impressumContent = ` +

Angaben gemäß § 5 TMG

+

+ Hammerschmidt u. Mössle GbR
+ Grockelhofen 10
+ 89340 Leipheim +

+ +

Kontakt

+

+ Telefon: +49 (0) 8221 / 204433
+ E-Mail: info@hms-licht-ton.de +

+ +

Vertretungsberechtigte

+

+ Markus Hammerschmidt
+ Thomas Mössle +

+ +

Registereintrag

+

+ Eintragung im Registergericht: Amtsgericht Günzburg
+ Registernummer: HRA 12345 (Beispiel – wird nachgeliefert) +

+ +

Umsatzsteuer-ID

+

+ Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz:
+ DE 123456789 (Beispiel – wird nachgeliefert) +

+ +

Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV

+

+ Markus Hammerschmidt
+ Grockelhofen 10
+ 89340 Leipheim +

+ +

Haftung für Inhalte

+

+ Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den + allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht + verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach + Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. +

+

+ Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen + Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt + der Kenntnis einer konkreten Rechtsverletzung möglich. +

+ +

Haftung für Links

+

+ Unser Angebot enthält ggf. Links zu externen Websites Dritter, auf deren Inhalte wir keinen + Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. + Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der + Seiten verantwortlich. +

+ +

Urheberrecht

+

+ Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem + deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der + Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des + jeweiligen Autors bzw. Erstellers. +

+`; + +export default function Impressum() { + return ; +} diff --git a/frontend/src/pages/Index.tsx b/frontend/src/pages/Index.tsx new file mode 100644 index 0000000..e21ed0d --- /dev/null +++ b/frontend/src/pages/Index.tsx @@ -0,0 +1,152 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import SpeakerIcon from '../components/SpeakerIcon'; +import ServiceCard from '../components/ServiceCard'; + +const services = [ + { icon: '\u{1F50A}', title: 'Vermietung', description: 'Lautsprecher, Mischpulte, Lichtanlagen, Rigging und Komplett-Setups aus unserem 1.000+ Geräte umfassenden Mietlager in Ellzee. Geprüftes Equipment, sofort einsatzbereit.' }, + { icon: '\u{1F6D2}', title: 'Verkauf', description: 'Neu- und Gebrauchtgeräte führender Hersteller mit Beratung, Inbetriebnahme und Service. Wir liefern nicht nur – wir konfigurieren Ihre Anlage einsatzfertig.' }, + { icon: '\u{1F477}', title: 'Personal', description: 'Qualifizierte Tontechniker, Lichttechniker und Rigger für Aufbau, Betrieb und Abbau. Mit TÜV-geprüfter Ausbildung und umfangreicher Praxiserfahrung.' }, + { icon: '\u{1F69A}', title: 'Transport', description: 'Eigene Transportfahrzeuge für sichere An- und Ablieferung. Inklusive Verladekonzept, Positionierung und Rückholung – kundengerecht terminiert.' }, + { icon: '\u{1F4E6}', title: 'Lagerung', description: 'Klimatisierte und trockene Lagerung für Equipment und Veranstaltungsbestände. Mit Bestandsmanagement und regelmäßiger Funktionsprüfung.' }, + { icon: '\u{1F527}', title: 'Werkstatt', description: 'Herstellerübergreifende Reparatur und Wartung in unserer eigenen Werkstatt. Regelmäßige DGUV-Prüfungen und Kalibrierung für Ihren sicheren Betrieb.' }, + { icon: '\u26A1', title: 'Installation', description: 'Festinstallationen in Schaufenstern, Lokalen und Veranstaltungsstätten. Von der Planung über die Elektroinstallation bis zur Abnahme – alles aus einer Hand.' }, + { icon: '\u{1F4C5}', title: 'Booking', description: 'Vermittlung von Künstlern und Technik-Personal für Ihre Veranstaltung. Mit unserem branchenweiten Netzwerk finden wir die passenden Professionals für Ihr Event.' } +]; + +const speakerIcons = ['linearray', 'subwoofer', 'pointsource', 'column', 'monitor', 'movinghead']; + +interface EquipmentItem { + id: number; + name: string; + number: string | null; + category: string | null; + image_url: string | null; + available: boolean; +} + +export default function Index() { + const navigate = useNavigate(); + const [highlights, setHighlights] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch('/api/equipment?page_size=6&sort=name_asc') + .then(r => r.json()) + .then(data => { + setHighlights(data.items || []); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + function goTo(route: string) { + navigate(route); + window.scrollTo(0, 0); + } + + return ( +
+
+
+
+
+
+ Seit 2003 · Über 20 Jahre Veranstaltungstechnik +

+ Professionelle Technik
für Ihre Veranstaltung +

+

+ Von der Firmenevent-Beschallung bis zur Open-Air-Großproduktion: HMS Licht & Ton liefert Tontechnik, Lichttechnik, Rigging und Komplettlösungen aus Leipheim und Ellzee. +

+
+ + +
+
+
+
+ +
+
+
+ Equipment-Highlights +

Lautsprecher & Strahler aus unserem Mietlager

+
+
+ {loading ? ( + Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+ )) + ) : ( + highlights.map((item, i) => ( +
goTo('/mietkatalog')} role="button" tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && goTo('/mietkatalog')}> + +
{item.name}
+
{item.category || 'Equipment'}
+
+ )) + )} +
+
+
+ +
+
+
+
+ Unternehmen +

Ihr Technik-Partner mit Erfahrung

+

Die Hammerschmidt u. Mössle GbR ist seit 2003 Ihr zuverlässiger Partner für Veranstaltungstechnik in der Region Leipheim / Ellzee und im gesamten süddeutschen Raum.

+

Mit einem Mietlager von über 1.000 Geräten, einer eigenen Werkstatt und erfahrenem Personal realisieren wir Veranstaltungen jeder Größenordnung.

+
+
20+
Jahre Erfahrung
+
1.000+
Geräte im Lager
+
2
Standorte
+
+
+
+
+ Veranstaltungstechnik Setup +
+
+
+
+
+ +
+
+
+ Leistungen +

Von der Vermietung bis zur Komplettbetreuung

+
+
+ {services.map((s) => ( + + ))} +
+
+
+ +
+
+
+

Online-Mietkatalog

+

Durchsuchen Sie unser Equipment-Sortiment und stellen Sie Ihre Mietanfrage direkt online zusammen.

+ +
+
+
+
+ ); +} diff --git a/frontend/src/pages/Kontakt.tsx b/frontend/src/pages/Kontakt.tsx new file mode 100644 index 0000000..915c616 --- /dev/null +++ b/frontend/src/pages/Kontakt.tsx @@ -0,0 +1,140 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; + +export default function Kontakt() { + const [form, setForm] = useState({ name: '', email: '', phone: '', subject: '', message: '', privacy: false }); + const [errors, setErrors] = useState>({}); + const [submitted, setSubmitted] = useState(false); + const [submitting, setSubmitting] = useState(false); + + function validate(): boolean { + const e: Record = {}; + if (!form.name.trim()) e.name = 'Name ist erforderlich'; + if (!form.email.trim()) e.email = 'E-Mail ist erforderlich'; + else if (!/^\S+@\S+\.\S+$/.test(form.email)) e.email = 'Ungültige E-Mail-Adresse'; + if (!form.message.trim()) e.message = 'Nachricht ist erforderlich'; + if (!form.privacy) e.privacy = 'Bitte stimmen Sie der Datenschutzerklärung zu'; + setErrors(e); + return Object.keys(e).length === 0; + } + + function submit(e: React.FormEvent) { + e.preventDefault(); + if (!validate()) return; + setSubmitting(true); + setTimeout(() => { setSubmitting(false); setSubmitted(true); }, 1500); + } + + function resetForm() { + setForm({ name: '', email: '', phone: '', subject: '', message: '', privacy: false }); + setSubmitted(false); + } + + return ( +
+

Kontakt

+

Wir beraten Sie persönlich – telefonisch, per E-Mail oder über das Kontaktformular.

+
+
+
+

🏢 Büro Leipheim

+
+

Grockelhofen 10
89340 Leipheim

+ Route planen +
+
+
+

📦 Mietlager Ellzee

+
+

Zur Schönhalde 8
89352 Ellzee

+ Route planen +
+
+
+

🕐 Öffnungszeiten

+
+
Montag – Freitag10:00 – 18:00
+
Samstag & SonntagGeschlossen
+
+
+
+
+
👤
+

Leopold Hammerschmidt

+
Geschäftsführung
+ +49 (0) 172 6264796 + leopold.hammerschmidt@hms-licht-ton.de +
+
+
👤
+

Andreas Mössle

+
Geschäftsführung
+ +49 (0) 173 / 9014604 + andreas.moessle@hms-licht-ton.de +
+
+
+
+
+

Nachricht senden

+ {submitted ? ( +
+
+ +
+

Nachricht übermittelt

+

Vielen Dank für Ihre Anfrage. Wir melden uns innerhalb von 24 Stunden bei Ihnen.

+ +
+ ) : ( +
+
+
+ + setForm(f => ({ ...f, name: e.target.value }))} type="text" className={`hms-input ${errors.name ? 'hms-input-error' : ''}`} placeholder="Ihr Name" aria-invalid={!!errors.name} /> + {errors.name &&

{errors.name}

} +
+
+ + setForm(f => ({ ...f, email: e.target.value }))} type="email" className={`hms-input ${errors.email ? 'hms-input-error' : ''}`} placeholder="ihre@email.de" aria-invalid={!!errors.email} /> + {errors.email &&

{errors.email}

} +
+
+ + setForm(f => ({ ...f, phone: e.target.value }))} type="tel" className="hms-input" placeholder="+49 ..." /> +
+
+ + +
+
+ +