Compare commits
30 Commits
3bfa54b4b3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 489419a7d4 | |||
| f815e64499 | |||
| e5606c64c5 | |||
| a08bdaa3b1 | |||
| 36a1ad0b84 | |||
| 80cb971308 | |||
| 6483453437 | |||
| 1221350ed6 | |||
| 620f7b3153 | |||
| cd465e0564 | |||
| 00c23bc066 | |||
| 5f67cc9140 | |||
| a6375567b0 | |||
| 9a269aa54f | |||
| 30db3491c0 | |||
| 4c63166384 | |||
| 63316f1e92 | |||
| 8dae2c0301 | |||
| 16fde9fb7d | |||
| e0e170fe6a | |||
| 90a7d7f2e0 | |||
| 786cb0c040 | |||
| 7b1bebe7da | |||
| 0f569f2c6e | |||
| 22acc39017 | |||
| a1bba48cd7 | |||
| e220ff7db9 | |||
| db5080df48 | |||
| e9da21b57e | |||
| 88cff68f73 |
@@ -0,0 +1,32 @@
|
||||
# HMS Licht & Ton – Environment Configuration
|
||||
# Copy this file to .env and fill in real values.
|
||||
# NEVER commit the real .env file!
|
||||
|
||||
# --- Rentman API ---
|
||||
RENTMAN_API_TOKEN=
|
||||
|
||||
# --- Authentication ---
|
||||
JWT_SECRET=
|
||||
|
||||
# --- Database ---
|
||||
DATABASE_URL=postgresql+asyncpg://hms:hms@postgres:5432/hms
|
||||
|
||||
# --- Redis ---
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# --- SMTP (Email) ---
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=info@hms-licht-ton.de
|
||||
|
||||
# --- CORS ---
|
||||
CORS_ORIGINS=https://hms.media-on.de,http://localhost:3000
|
||||
|
||||
# --- Admin ---
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# --- Frontend ---
|
||||
NUXT_PUBLIC_API_BASE=http://backend:8000
|
||||
@@ -0,0 +1,80 @@
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
frontend-test:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup npm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: frontend/node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests
|
||||
working-directory: frontend
|
||||
run: npm test
|
||||
|
||||
- name: Build production
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
|
||||
backend-test:
|
||||
runs-on: ubuntu-latest
|
||||
container: python:3.12-slim
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
- name: Setup pip cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Install Python dependencies
|
||||
working-directory: backend
|
||||
run: pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
- name: Run tests
|
||||
working-directory: backend
|
||||
run: pytest -v
|
||||
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [frontend-test, backend-test]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build frontend Docker image
|
||||
run: docker build -t hms-frontend:ci ./frontend
|
||||
|
||||
- name: Build backend Docker image
|
||||
run: docker build -t hms-backend:ci ./backend
|
||||
|
||||
- name: Validate docker-compose
|
||||
run: docker compose config --quiet
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.coverage
|
||||
.env
|
||||
*.db
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
node_modules/
|
||||
.nuxt/
|
||||
dist/
|
||||
images/
|
||||
@@ -3,6 +3,9 @@
|
||||
> **Projekt:** hms-licht-ton (ID 30)
|
||||
> **Stack:** Nuxt 3 (Frontend) + FastAPI (Backend) + PostgreSQL + Redis
|
||||
> **Deployment:** Coolify (Docker) auf coolify-01
|
||||
>
|
||||
> **DESIGN-RICHTLINIEN:** `docs/design-rules.md` – VERBINDLICH für alle Erweiterungen!
|
||||
> Jeder KI-Agent MUSS diese Regeln vor der Implementierung lesen und einhalten.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# HMS Licht & Ton – Homepage & Verwaltungssystem
|
||||
|
||||
Die offizielle Website und Verwaltungsplattform für HMS Licht & Ton (Hammerschmidt u. Mössle GbR) aus Leipheim/Ellzee. Professionelle Veranstaltungstechnik – Vermietung, Verkauf, Personal und Transport von Tontechnik und Lichttechnik.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Komponente | Technologie |
|
||||
|---------------|-----------------------------------|
|
||||
| Frontend | Nuxt 3 (Vue 3, TypeScript, Tailwind CSS) |
|
||||
| Backend | FastAPI (Python 3.12, async) |
|
||||
| Database | PostgreSQL 16 |
|
||||
| Cache | Redis 7 |
|
||||
| Deployment | Docker Compose via Coolify |
|
||||
| CI/CD | Forgejo Actions |
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js** 20+
|
||||
- **Python** 3.12+
|
||||
- **Docker** & Docker Compose
|
||||
- **Git**
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Repository klonen
|
||||
|
||||
```bash
|
||||
git clone https://forgejo.media-on.de/Leopoldadmin/hms-licht-ton.git
|
||||
cd hms-licht-ton
|
||||
```
|
||||
|
||||
### 2. Environment konfigurieren
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# .env mit echten Werten ausfüllen (API-Token, Secrets, SMTP, etc.)
|
||||
```
|
||||
|
||||
### 3. Anwendung starten (Docker)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Die Anwendung ist danach erreichbar unter:
|
||||
- **Frontend:** http://localhost:3000
|
||||
- **Backend API:** http://localhost:8000
|
||||
- **API Docs (Swagger):** http://localhost:8000/docs
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Frontend (Nuxt 3)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run dev # Dev-Server auf http://localhost:3000
|
||||
npm test # Unit Tests (Vitest)
|
||||
npx playwright test # E2E Tests
|
||||
npm run build # Production Build
|
||||
npm run typecheck # TypeScript Type Check
|
||||
npm run lint # ESLint
|
||||
```
|
||||
|
||||
### Backend (FastAPI)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
pytest -v # Tests ausführen
|
||||
```
|
||||
|
||||
Weitere Build- und Test-Befehle siehe [AGENTS.md](AGENTS.md).
|
||||
|
||||
---
|
||||
|
||||
## Build (Docker)
|
||||
|
||||
### Alle Services bauen
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
```
|
||||
|
||||
### Einzelne Services bauen
|
||||
|
||||
```bash
|
||||
docker compose build frontend
|
||||
docker compose build backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment (Coolify)
|
||||
|
||||
Die Anwendung wird über Coolify auf `coolify-01` (46.225.91.159) deployt.
|
||||
|
||||
1. In Coolify ein neues Projekt "HMS Licht & Ton" anlegen.
|
||||
2. Als Source das Forgejo-Repository `Leopoldadmin/hms-licht-ton` auswählen.
|
||||
3. Als Deployment-Methode "Docker Compose" wählen und `docker-compose.yml` referenzieren.
|
||||
4. Environment-Variablen in Coolify setzen (entsprechend `.env.example`).
|
||||
5. Domain für Frontend konfigurieren (z.B. `hms.media-on.de` → Port 3000).
|
||||
6. Backend-Port (8000) nur intern暴露en oder über Traefik mit eigener Subdomain.
|
||||
7. Deploy auslösen. Coolify baut die Images und startet alle Services.
|
||||
|
||||
### Health Checks
|
||||
|
||||
Alle Services haben Health Checks konfiguriert:
|
||||
- **Frontend:** HTTP-Check auf Port 3000
|
||||
- **Backend:** HTTP-Check auf `/health` Endpoint
|
||||
- **PostgreSQL:** `pg_isready`
|
||||
- **Redis:** `redis-cli ping`
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Pipeline (Forgejo Actions)
|
||||
|
||||
Die Pipeline (`.forgejo/workflows/ci.yml`) läuft bei jedem Push und PR auf `main`:
|
||||
|
||||
1. **frontend-test:** `npm ci` → `npm test` → `npm run build`
|
||||
2. **backend-test:** `pip install` → `pytest -v`
|
||||
3. **docker-build:** Baut Frontend- und Backend-Docker-Images, validiert `docker-compose.yml`
|
||||
|
||||
---
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
hms-licht-ton/
|
||||
├── frontend/ # Nuxt 3 Frontend
|
||||
│ ├── Dockerfile
|
||||
│ ├── .dockerignore
|
||||
│ ├── package.json
|
||||
│ ├── nuxt.config.ts
|
||||
│ ├── pages/
|
||||
│ ├── components/
|
||||
│ ├── composables/
|
||||
│ ├── stores/
|
||||
│ └── tests/
|
||||
├── backend/ # FastAPI Backend
|
||||
│ ├── Dockerfile
|
||||
│ ├── .dockerignore
|
||||
│ ├── requirements.txt
|
||||
│ ├── app/
|
||||
│ │ ├── main.py
|
||||
│ │ ├── config.py
|
||||
│ │ ├── database.py
|
||||
│ │ ├── auth.py
|
||||
│ │ ├── cache.py
|
||||
│ │ ├── routers/
|
||||
│ │ └── schemas/
|
||||
│ └── tests/
|
||||
├── docs/ # Projektdokumentation
|
||||
├── .forgejo/
|
||||
│ └── workflows/
|
||||
│ └── ci.yml # CI/CD Pipeline
|
||||
├── docker-compose.yml # 4 Services: frontend, backend, postgres, redis
|
||||
├── .env.example # Environment-Vorlage
|
||||
├── .gitignore
|
||||
├── AGENTS.md # Build & Test Guide
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
© Hammerschmidt u. Mössle GbR. Alle Rechte vorbehalten.
|
||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.coverage
|
||||
.env
|
||||
.env.*
|
||||
.pytest_cache
|
||||
htmlcov
|
||||
tests
|
||||
*.md
|
||||
docs
|
||||
@@ -0,0 +1,14 @@
|
||||
DATABASE_URL=sqlite+aiosqlite:///./test.db
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
RENTMAN_API_TOKEN=test-token
|
||||
JWT_SECRET=test-secret-for-testing-only
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRE_HOURS=24
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=test
|
||||
SMTP_PASSWORD=test
|
||||
SMTP_FROM=info@hms-licht-ton.de
|
||||
CORS_ORIGINS=http://localhost:3000,https://hms.media-on.de
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change_me_in_production
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies for asyncpg + git + docker-cli (MCP tools)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc libpq-dev git docker.io curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ ./app/
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
"""JWT authentication helpers."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
import bcrypt
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models.admin_user import AdminUser
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a plain password against a hash."""
|
||||
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash a password using bcrypt."""
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
"""Create a JWT access token."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (
|
||||
expires_delta or timedelta(hours=settings.jwt_expire_hours)
|
||||
)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def verify_token(token: str) -> dict | None:
|
||||
"""Verify a JWT token and return its payload."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AdminUser:
|
||||
"""Dependency: extract JWT from cookie, verify, return AdminUser."""
|
||||
token = request.cookies.get("hms_admin_token")
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
)
|
||||
payload = verify_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
)
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token payload",
|
||||
)
|
||||
result = await db.execute(select(AdminUser).where(AdminUser.username == username))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
)
|
||||
return user
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Redis cache client and helpers."""
|
||||
import json
|
||||
from typing import Any
|
||||
import redis.asyncio as redis
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
async def get_redis() -> redis.Redis:
|
||||
"""Return a Redis client instance."""
|
||||
return redis.from_url(settings.redis_url, decode_responses=True)
|
||||
|
||||
|
||||
class CacheClient:
|
||||
"""Wrapper around Redis for equipment caching."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._redis: redis.Redis | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
self._redis = await get_redis()
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if self._redis:
|
||||
await self._redis.close()
|
||||
self._redis = None
|
||||
|
||||
async def get(self, key: str) -> Any | None:
|
||||
if not self._redis:
|
||||
await self.connect()
|
||||
assert self._redis is not None
|
||||
data = await self._redis.get(key)
|
||||
if data:
|
||||
return json.loads(data)
|
||||
return None
|
||||
|
||||
async def set(self, key: str, value: Any, ttl: int = 3600) -> None:
|
||||
if not self._redis:
|
||||
await self.connect()
|
||||
assert self._redis is not None
|
||||
await self._redis.setex(key, ttl, json.dumps(value, default=str))
|
||||
|
||||
async def delete_pattern(self, pattern: str) -> int:
|
||||
if not self._redis:
|
||||
await self.connect()
|
||||
assert self._redis is not None
|
||||
keys = []
|
||||
async for key in self._redis.scan_iter(match=pattern):
|
||||
keys.append(key)
|
||||
if keys:
|
||||
return await self._redis.delete(*keys)
|
||||
return 0
|
||||
|
||||
async def incr_rate(self, key: str, window: int = 60) -> int:
|
||||
if not self._redis:
|
||||
await self.connect()
|
||||
assert self._redis is not None
|
||||
count = await self._redis.incr(key)
|
||||
if count == 1:
|
||||
await self._redis.expire(key, window)
|
||||
return count
|
||||
|
||||
|
||||
cache = CacheClient()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Application configuration via Pydantic Settings."""
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Settings loaded from environment variables."""
|
||||
|
||||
# Database
|
||||
database_url: str = "sqlite+aiosqlite:///./test.db"
|
||||
|
||||
# Redis
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# Rentman
|
||||
rentman_api_token: str = ""
|
||||
|
||||
# JWT
|
||||
jwt_secret: str = "change-me-in-production"
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_expire_hours: int = 24
|
||||
|
||||
# SMTP
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from: str = "info@hms-licht-ton.de"
|
||||
|
||||
# CORS
|
||||
cors_origins: str = "https://hms.media-on.de,http://localhost:3000"
|
||||
|
||||
# Admin seed
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "change_me_in_production"
|
||||
|
||||
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Return cached settings instance."""
|
||||
return Settings()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""SQLAlchemy async database engine and session."""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for all models."""
|
||||
pass
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
async_session = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Dependency: yield an async database session."""
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create all tables (for testing / first run)."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""FastAPI application assembly with CORS, routers, and APScheduler."""
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
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.services.sync_service import SyncService
|
||||
from app.services.email_service import EmailService
|
||||
from app.mcp_server import get_mcp_app, mcp_session_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
async def run_equipment_sync() -> None:
|
||||
"""Scheduled job: run equipment sync every 6 hours."""
|
||||
logger.info("Starting scheduled equipment sync")
|
||||
async with async_session() as db:
|
||||
sync_service = SyncService(db)
|
||||
result = await sync_service.run_sync()
|
||||
logger.info("Scheduled sync complete: %s", result)
|
||||
|
||||
|
||||
async def run_email_retry() -> None:
|
||||
"""Scheduled job: retry failed emails every 15 minutes."""
|
||||
logger.info("Starting scheduled email retry")
|
||||
async with async_session() as db:
|
||||
sent = await EmailService.retry_failed_emails(db)
|
||||
logger.info("Email retry complete: %d emails sent", sent)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: init DB, start scheduler, connect cache."""
|
||||
await init_db()
|
||||
await cache.connect()
|
||||
|
||||
_mcp_cm = mcp_session_manager.run()
|
||||
await _mcp_cm.__aenter__()
|
||||
|
||||
scheduler.add_job(run_equipment_sync, trigger="cron", hour="*/6", id="equipment_sync", replace_existing=True)
|
||||
scheduler.add_job(run_email_retry, trigger="cron", minute="*/15", id="email_retry", replace_existing=True)
|
||||
scheduler.start()
|
||||
logger.info("APScheduler started")
|
||||
|
||||
yield
|
||||
|
||||
scheduler.shutdown(wait=False)
|
||||
await _mcp_cm.__aexit__(None, None, None)
|
||||
await cache.disconnect()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(title="HMS Licht & Ton API", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
||||
app.add_middleware(CORSMiddleware, allow_origins=origins, allow_methods=["GET", "POST"], allow_headers=["*"], allow_credentials=True)
|
||||
|
||||
app.include_router(equipment.router)
|
||||
app.include_router(health.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(rental_requests.router)
|
||||
app.include_router(contact.router)
|
||||
|
||||
app.mount("/mcp", get_mcp_app())
|
||||
@@ -0,0 +1,558 @@
|
||||
"""MCP Server for HMS CMS – provides resources and tools for AI agents.
|
||||
|
||||
Resources (read-only):
|
||||
- design-rules: HMS design system as JSON
|
||||
- page-structure: React pages and components map
|
||||
- sync-status: current Rentman sync status
|
||||
|
||||
Tools (execute):
|
||||
- trigger_sync: run Rentman equipment sync
|
||||
- get_sync_log: fetch sync history
|
||||
- set_rentman_token: update .env token + restart backend
|
||||
- read_file: read frontend source file
|
||||
- write_file: write frontend source file
|
||||
- create_page: create React page with design validation
|
||||
- deploy: rebuild + redeploy frontend container
|
||||
- git_commit: commit all changes
|
||||
- git_status: show working tree status
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from app.database import async_session
|
||||
from app.services.sync_service import SyncService
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Paths (mapped via docker-compose volumes)
|
||||
# ---------------------------------------------------------------------------#
|
||||
REPO_ROOT = Path("/data/repo")
|
||||
FRONTEND_ROOT = REPO_ROOT / "frontend"
|
||||
FRONTEND_SRC = FRONTEND_ROOT / "src"
|
||||
ENV_FILE = REPO_ROOT / ".env"
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Static design rules (derived from frontend/src/index.css)
|
||||
# ---------------------------------------------------------------------------#
|
||||
DESIGN_RULES: dict[str, Any] = {
|
||||
"colors": {
|
||||
"accent": "#FA5C01",
|
||||
"bg": "#131313",
|
||||
"panel": "#1a1a1a",
|
||||
"surface": "#212121",
|
||||
"text": "#ffffff",
|
||||
"text_muted": "#d4d4d4",
|
||||
"border": "#444444a8",
|
||||
"secondary": "#656565",
|
||||
},
|
||||
"fonts": {"primary": "Inter", "weights": [400, 500, 600, 700, 800]},
|
||||
"components": {
|
||||
"hms-btn": "button class with variants: primary, secondary, ghost",
|
||||
"hms-card": "card container with border, bg, hover effect",
|
||||
"hms-badge": "badge with variants: primary, gray, success, error",
|
||||
"hms-input": "input field with focus state",
|
||||
"hms-chip": "filter chip with active state",
|
||||
"hms-skeleton": "loading skeleton with shimmer animation",
|
||||
"hms-speaker-grid": "responsive grid for equipment items",
|
||||
"hms-gallery": "responsive gallery grid",
|
||||
"hms-hero": "hero section with bg image and overlay",
|
||||
},
|
||||
"layout": {
|
||||
"max_width": "1280px",
|
||||
"header_height": "4rem",
|
||||
"radius": {"sm": 2, "md": 3, "lg": 4, "xl": 4, "full": 9999},
|
||||
},
|
||||
"rules": [
|
||||
"Use Inter font for all text",
|
||||
"Use #FA5C01 for all accent elements",
|
||||
"Use hms-* CSS classes, do not invent new ones",
|
||||
"All text in German",
|
||||
"Use CSS variables (var(--text), var(--bg), etc.) not hardcoded colors",
|
||||
"Use Tailwind utility classes for layout, hms-* classes for components",
|
||||
"Images: use object-cover, lazy loading",
|
||||
"Buttons: use hms-btn with variant classes",
|
||||
"Cards: use hms-card class",
|
||||
"Responsive: sm: breakpoint 640px, lg: breakpoint 1024px",
|
||||
],
|
||||
}
|
||||
|
||||
# Static page/component info (used when frontend source is not mounted)
|
||||
_STATIC_PAGES = [
|
||||
{"path": "/", "file": "src/pages/Index.tsx", "components": ["SpeakerIcon", "ServiceCard"]},
|
||||
{"path": "/mietkatalog", "file": "src/pages/Mietkatalog.tsx", "components": ["EquipmentCard", "LoadingSkeleton", "ErrorState", "EmptyState"]},
|
||||
{"path": "/mietkatalog/:id", "file": "src/pages/EquipmentDetail.tsx", "components": ["ErrorState"]},
|
||||
{"path": "/kontakt", "file": "src/pages/Kontakt.tsx", "components": []},
|
||||
{"path": "/referenzen", "file": "src/pages/Referenzen.tsx", "components": ["ErrorState", "EmptyState"]},
|
||||
{"path": "/warenkorb", "file": "src/pages/Warenkorb.tsx", "components": ["EmptyState"]},
|
||||
{"path": "/admin", "file": "src/pages/Admin.tsx", "components": ["Logo"]},
|
||||
{"path": "/agb", "file": "src/pages/AGB.tsx", "components": ["LegalContentPage"]},
|
||||
{"path": "/datenschutz", "file": "src/pages/Datenschutz.tsx", "components": ["LegalContentPage"]},
|
||||
{"path": "/impressum", "file": "src/pages/Impressum.tsx", "components": ["LegalContentPage"]},
|
||||
{"path": "/*", "file": "src/pages/ErrorPage.tsx", "components": ["Header", "Footer"]},
|
||||
]
|
||||
|
||||
_STATIC_COMPONENTS = [
|
||||
{"name": "Header", "file": "src/components/Header.tsx", "props": []},
|
||||
{"name": "Footer", "file": "src/components/Footer.tsx", "props": []},
|
||||
{"name": "Logo", "file": "src/components/Logo.tsx", "props": ["size"]},
|
||||
{"name": "EquipmentCard", "file": "src/components/EquipmentCard.tsx", "props": ["item", "onClick", "onAddToCart"]},
|
||||
{"name": "LoadingSkeleton", "file": "src/components/LoadingSkeleton.tsx", "props": ["count"]},
|
||||
{"name": "ErrorState", "file": "src/components/ErrorState.tsx", "props": ["title", "message", "onRetry"]},
|
||||
{"name": "EmptyState", "file": "src/components/EmptyState.tsx", "props": ["icon", "title", "message", "actionLabel", "onAction"]},
|
||||
{"name": "ServiceCard", "file": "src/components/ServiceCard.tsx", "props": ["icon", "title", "description"]},
|
||||
{"name": "SpeakerIcon", "file": "src/components/SpeakerIcon.tsx", "props": ["type"]},
|
||||
{"name": "LegalContentPage", "file": "src/components/LegalContentPage.tsx", "props": ["title", "content", "loading"]},
|
||||
{"name": "Layout", "file": "src/components/Layout.tsx", "props": []},
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# FastMCP server instance
|
||||
# ---------------------------------------------------------------------------#
|
||||
# streamable_http_path="/" so that when mounted at /mcp the endpoint is /mcp/
|
||||
# stateless_http=True: each request is independent (no session management)
|
||||
# Disable DNS rebinding protection for Docker deployment
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
|
||||
mcp = FastMCP("hms-cms", streamable_http_path="/", stateless_http=True, transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False))
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Bearer token authentication wrapper
|
||||
# ---------------------------------------------------------------------------#
|
||||
_MCP_TOKEN = os.environ.get("MCP_AUTH_TOKEN", "")
|
||||
|
||||
|
||||
class MCPAuthMiddleware:
|
||||
"""ASGI middleware: validate Authorization: Bearer <token> for /mcp endpoint."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
return await self.app(scope, receive, send)
|
||||
|
||||
# If no token configured, allow all (dev mode)
|
||||
if not _MCP_TOKEN:
|
||||
return await self.app(scope, receive, send)
|
||||
|
||||
# Extract Authorization header
|
||||
headers = dict(scope.get("headers", []))
|
||||
auth_header = headers.get(b"authorization", b"").decode()
|
||||
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
if token == _MCP_TOKEN:
|
||||
return await self.app(scope, receive, send)
|
||||
|
||||
# Reject
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": 401,
|
||||
"headers": [[b"content-type", b"application/json"], [b"www-authenticate", b"Bearer"]],
|
||||
})
|
||||
await send({
|
||||
"type": "http.response.body",
|
||||
"body": b'{"error": "Unauthorized: invalid or missing Bearer token"}',
|
||||
})
|
||||
|
||||
|
||||
def get_mcp_app():
|
||||
"""Return the MCP ASGI app wrapped with auth middleware."""
|
||||
return MCPAuthMiddleware(_mcp_streamable_app)
|
||||
|
||||
|
||||
# Expose session manager for lifespan integration in main FastAPI app.
|
||||
# FastAPI mount() does not call sub-app lifespans, so we must
|
||||
# manually start/stop the session manager in the main app lifespan.
|
||||
# Call streamable_http_app() first to lazily initialize the session manager.
|
||||
_mcp_streamable_app = mcp.streamable_http_app()
|
||||
mcp_session_manager = mcp._session_manager
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Resources (read-only)
|
||||
# ---------------------------------------------------------------------------#
|
||||
@mcp.resource("hms://design-rules")
|
||||
async def design_rules() -> str:
|
||||
"""Return the HMS design rules as structured JSON."""
|
||||
return json.dumps(DESIGN_RULES, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
@mcp.resource("hms://page-structure")
|
||||
async def page_structure() -> str:
|
||||
"""List all React pages and their component usage."""
|
||||
pages = list(_STATIC_PAGES)
|
||||
components = list(_STATIC_COMPONENTS)
|
||||
|
||||
# Dynamic scan if frontend source is accessible
|
||||
if FRONTEND_SRC.exists():
|
||||
pages_dir = FRONTEND_SRC / "pages"
|
||||
comp_dir = FRONTEND_SRC / "components"
|
||||
|
||||
if pages_dir.exists():
|
||||
dyn_pages: list[dict] = []
|
||||
for f in sorted(pages_dir.glob("*.tsx")):
|
||||
content = f.read_text()
|
||||
imports = re.findall(r"import\s+(\w+)\s+from\s+['\"]\.\.?/", content)
|
||||
comp_imports = [imp for imp in imports if imp[0].isupper()]
|
||||
stem = f.stem
|
||||
if stem == "Index":
|
||||
path = "/"
|
||||
elif stem == "ErrorPage":
|
||||
path = "/*"
|
||||
elif stem == "EquipmentDetail":
|
||||
path = "/mietkatalog/:id"
|
||||
else:
|
||||
path = f"/{stem.lower()}"
|
||||
dyn_pages.append({
|
||||
"path": path,
|
||||
"file": f"src/pages/{f.name}",
|
||||
"components": comp_imports,
|
||||
})
|
||||
if dyn_pages:
|
||||
pages = dyn_pages
|
||||
|
||||
if comp_dir.exists():
|
||||
dyn_comps: list[dict] = []
|
||||
for f in sorted(comp_dir.glob("*.tsx")):
|
||||
content = f.read_text()
|
||||
props: list[str] = []
|
||||
# Match interface Props { ... } or interface XxxProps { ... }
|
||||
prop_match = re.search(r"interface\s+\w+\s*\{([^}]*)\}", content)
|
||||
if prop_match:
|
||||
props = re.findall(r"(\w+)\??\s*:", prop_match.group(1))
|
||||
dyn_comps.append({
|
||||
"name": f.stem,
|
||||
"file": f"src/components/{f.name}",
|
||||
"props": props,
|
||||
})
|
||||
if dyn_comps:
|
||||
components = dyn_comps
|
||||
|
||||
return json.dumps({"pages": pages, "components": components}, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
@mcp.resource("hms://sync-status")
|
||||
async def sync_status() -> str:
|
||||
"""Return the current sync status (last sync, items, errors)."""
|
||||
try:
|
||||
async with async_session() as db:
|
||||
svc = SyncService(db)
|
||||
result = await svc.get_last_sync()
|
||||
return json.dumps(result, indent=2, default=str, ensure_ascii=False)
|
||||
except Exception as exc:
|
||||
return json.dumps({"error": str(exc)}, indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Tools (execute)
|
||||
# ---------------------------------------------------------------------------#
|
||||
@mcp.tool()
|
||||
async def trigger_sync() -> str:
|
||||
"""Trigger a Rentman equipment sync (like POST /api/admin/sync)."""
|
||||
try:
|
||||
async with async_session() as db:
|
||||
svc = SyncService(db)
|
||||
result = await svc.run_sync()
|
||||
return json.dumps(result, indent=2)
|
||||
except Exception as exc:
|
||||
return json.dumps({"error": str(exc)}, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_sync_log(page: int = 1, page_size: int = 20) -> str:
|
||||
"""Return the sync history log.
|
||||
|
||||
Args:
|
||||
page: Page number (1-based)
|
||||
page_size: Items per page (max 100)
|
||||
"""
|
||||
try:
|
||||
async with async_session() as db:
|
||||
svc = SyncService(db)
|
||||
result = await svc.get_sync_log_paginated(page=page, page_size=page_size)
|
||||
items = []
|
||||
for log in result["items"]:
|
||||
items.append({
|
||||
"id": log.id,
|
||||
"sync_type": log.sync_type,
|
||||
"status": log.status,
|
||||
"started_at": log.started_at.isoformat() if log.started_at else None,
|
||||
"completed_at": log.completed_at.isoformat() if log.completed_at else None,
|
||||
"items_processed": log.items_processed,
|
||||
"items_failed": log.items_failed,
|
||||
"error_message": log.error_message,
|
||||
})
|
||||
return json.dumps({
|
||||
"items": items,
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
"total_pages": result["total_pages"],
|
||||
}, indent=2, default=str)
|
||||
except Exception as exc:
|
||||
return json.dumps({"error": str(exc)}, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def set_rentman_token(token: str) -> str:
|
||||
"""Update the Rentman API Token in .env and restart the backend.
|
||||
|
||||
Args:
|
||||
token: New Rentman API token string
|
||||
"""
|
||||
try:
|
||||
if not ENV_FILE.exists():
|
||||
return json.dumps({"error": f".env file not found at {ENV_FILE}"})
|
||||
|
||||
content = ENV_FILE.read_text()
|
||||
|
||||
if "RENTMAN_API_TOKEN=" in content:
|
||||
content = re.sub(
|
||||
r"RENTMAN_API_TOKEN=.*",
|
||||
f"RENTMAN_API_TOKEN={token}",
|
||||
content,
|
||||
)
|
||||
else:
|
||||
content += f"\nRENTMAN_API_TOKEN={token}\n"
|
||||
|
||||
ENV_FILE.write_text(content)
|
||||
|
||||
# Restart backend with a short delay so the response can be sent first
|
||||
async def _restart() -> None:
|
||||
await asyncio.sleep(2)
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "compose", "restart", "backend"],
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Backend restart failed: %s", exc)
|
||||
|
||||
asyncio.create_task(_restart())
|
||||
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"message": "Token updated. Backend restart initiated (2s delay).",
|
||||
})
|
||||
except Exception as exc:
|
||||
return json.dumps({"error": str(exc)}, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def read_file(path: str) -> str:
|
||||
"""Read a file from the frontend source.
|
||||
|
||||
Args:
|
||||
path: Relative path to the file (relative to frontend/)
|
||||
"""
|
||||
try:
|
||||
full_path = FRONTEND_ROOT / path
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
return json.dumps({"error": f"File not found: {path}"})
|
||||
# Security: ensure path stays within frontend dir
|
||||
full_path = full_path.resolve()
|
||||
if not str(full_path).startswith(str(FRONTEND_ROOT.resolve())):
|
||||
return json.dumps({"error": "Path traversal blocked"})
|
||||
return full_path.read_text()
|
||||
except Exception as exc:
|
||||
return json.dumps({"error": str(exc)}, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def write_file(path: str, content: str) -> str:
|
||||
"""Write a file in the frontend source.
|
||||
|
||||
Args:
|
||||
path: Relative path (relative to frontend/src/)
|
||||
content: File content to write
|
||||
"""
|
||||
try:
|
||||
full_path = (FRONTEND_SRC / path).resolve()
|
||||
if not str(full_path).startswith(str(FRONTEND_SRC.resolve())):
|
||||
return json.dumps({"error": "Path traversal blocked"})
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"path": str(full_path),
|
||||
"bytes": len(content),
|
||||
})
|
||||
except Exception as exc:
|
||||
return json.dumps({"error": str(exc)}, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def create_page(name: str, content: str) -> str:
|
||||
"""Create a new React page with design rule validation.
|
||||
|
||||
Args:
|
||||
name: Page name (e.g. 'UeberUns' creates src/pages/UeberUns.tsx)
|
||||
content: Full TSX content of the page
|
||||
"""
|
||||
try:
|
||||
issues: list[str] = []
|
||||
|
||||
# Validate: check for non-hms component classes
|
||||
custom_classes = re.findall(r'className="([^"]*)"', content)
|
||||
for cls_str in custom_classes:
|
||||
for word in cls_str.split():
|
||||
if word.startswith("btn") and not word.startswith("hms-btn"):
|
||||
issues.append(f"Found '{word}' – use 'hms-btn' instead")
|
||||
if word.startswith("card") and not word.startswith("hms-card"):
|
||||
issues.append(f"Found '{word}' – use 'hms-card' instead")
|
||||
if word.startswith("badge") and not word.startswith("hms-badge"):
|
||||
issues.append(f"Found '{word}' – use 'hms-badge' instead")
|
||||
|
||||
# Validate: hardcoded hex colors (excluding #FA5C01 accent)
|
||||
hex_colors = re.findall(r"#([0-9A-Fa-f]{6})\b", content)
|
||||
for hc in hex_colors:
|
||||
if hc.upper() not in ("FA5C01", "FFFFFF", "000000"):
|
||||
issues.append(f"Hardcoded color #{hc} – use CSS variable instead")
|
||||
|
||||
# Validate: non-Inter font-family
|
||||
font_match = re.search(r'font-family:\s*["\']?(?!Inter)["\']?', content)
|
||||
if font_match:
|
||||
issues.append("Non-Inter font detected – use Inter for all text")
|
||||
|
||||
# Write the file
|
||||
safe_name = re.sub(r"[^a-zA-Z0-9_-]", "", name)
|
||||
if not safe_name:
|
||||
return json.dumps({"error": "Invalid page name"})
|
||||
filename = f"{safe_name}.tsx"
|
||||
file_path = FRONTEND_SRC / "pages" / filename
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content)
|
||||
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"file": f"src/pages/{filename}",
|
||||
"path": str(file_path),
|
||||
"validation": {
|
||||
"issues": issues,
|
||||
"passed": len(issues) == 0,
|
||||
},
|
||||
}, indent=2, ensure_ascii=False)
|
||||
except Exception as exc:
|
||||
return json.dumps({"error": str(exc)}, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def deploy() -> str:
|
||||
"""Build and deploy the frontend.
|
||||
|
||||
Runs: docker compose build --no-cache frontend
|
||||
docker compose up -d --force-recreate frontend
|
||||
docker compose restart frontend
|
||||
"""
|
||||
try:
|
||||
commands = [
|
||||
["docker", "compose", "build", "--no-cache", "frontend"],
|
||||
["docker", "compose", "up", "-d", "--force-recreate", "frontend"],
|
||||
["docker", "compose", "restart", "frontend"],
|
||||
]
|
||||
results = []
|
||||
for cmd in commands:
|
||||
r = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
results.append({
|
||||
"command": " ".join(cmd),
|
||||
"returncode": r.returncode,
|
||||
"stdout": r.stdout[-500:] if r.stdout else "",
|
||||
"stderr": r.stderr[-500:] if r.stderr else "",
|
||||
})
|
||||
if r.returncode != 0:
|
||||
return json.dumps({
|
||||
"status": "failed",
|
||||
"step": cmd[2],
|
||||
"results": results,
|
||||
}, indent=2)
|
||||
|
||||
return json.dumps({"status": "success", "results": results}, indent=2)
|
||||
except Exception as exc:
|
||||
return json.dumps({"error": str(exc)}, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def git_commit(message: str) -> str:
|
||||
"""Commit all changes with a message.
|
||||
|
||||
Args:
|
||||
message: Git commit message
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "add", "-A"],
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return json.dumps({"error": "git add failed", "stderr": r.stderr})
|
||||
|
||||
r = subprocess.run(
|
||||
["git", "commit", "-m", message],
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
if "nothing to commit" in r.stdout:
|
||||
return json.dumps({"status": "noop", "message": "Nothing to commit"})
|
||||
return json.dumps({"error": "git commit failed", "stderr": r.stderr})
|
||||
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"message": message,
|
||||
"output": r.stdout.strip(),
|
||||
})
|
||||
except Exception as exc:
|
||||
return json.dumps({"error": str(exc)}, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def git_status() -> str:
|
||||
"""Return the git working tree status."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return json.dumps({"error": r.stderr})
|
||||
|
||||
files = []
|
||||
for line in r.stdout.strip().split("\n"):
|
||||
if line:
|
||||
files.append({
|
||||
"status": line[:2].strip(),
|
||||
"file": line[3:].strip(),
|
||||
})
|
||||
|
||||
return json.dumps({"files": files, "clean": len(files) == 0}, indent=2)
|
||||
except Exception as exc:
|
||||
return json.dumps({"error": str(exc)}, indent=2)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Import all models for Alembic and Base metadata."""
|
||||
from app.models.equipment import EquipmentCache
|
||||
from app.models.rental_request import RentalRequest, RentalRequestItem
|
||||
from app.models.contact import Contact
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.sync_log import SyncLog
|
||||
from app.models.email_queue import EmailQueue
|
||||
|
||||
__all__ = ["EquipmentCache", "RentalRequest", "RentalRequestItem", "Contact", "AdminUser", "SyncLog", "EmailQueue"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,12 @@
|
||||
"""Admin user model."""
|
||||
from sqlalchemy import Column, Integer, String, TIMESTAMP, func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AdminUser(Base):
|
||||
__tablename__ = "admin_users"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
username = Column(String(64), unique=True, nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Contact form submission model."""
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, TIMESTAMP, func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Contact(Base):
|
||||
__tablename__ = "contacts"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
email = Column(String(255), nullable=False)
|
||||
phone = Column(String(64))
|
||||
message = Column(Text, nullable=False)
|
||||
privacy_consent = Column(Boolean, nullable=False)
|
||||
email_sent = Column(Boolean, default=False)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Email queue model for failed email retry."""
|
||||
from sqlalchemy import Column, Integer, String, Text, TIMESTAMP, func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class EmailQueue(Base):
|
||||
__tablename__ = "email_queue"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
to_addr = Column(String(255), nullable=False)
|
||||
subject = Column(String(255), nullable=False)
|
||||
html_body = Column(Text, nullable=False)
|
||||
text_body = Column(Text)
|
||||
reply_to = Column(String(255))
|
||||
status = Column(String(32), default="pending", nullable=False)
|
||||
attempts = Column(Integer, default=0, nullable=False)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Equipment cache model (imported from Rentman)."""
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DECIMAL, JSON, Index, TIMESTAMP, func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class EquipmentCache(Base):
|
||||
__tablename__ = "equipment_cache"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
rentman_id = Column(String(64), unique=True, nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
number = Column(String(64))
|
||||
category = Column(String(128))
|
||||
subcategory = Column(String(128))
|
||||
description = Column(Text)
|
||||
specifications = Column(JSON)
|
||||
images = Column(JSON)
|
||||
update_hash = Column(String(128))
|
||||
rental_price = Column(DECIMAL(10, 2))
|
||||
brand = Column(String(128))
|
||||
available = Column(Boolean, default=True)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@property
|
||||
def image_url(self) -> str | None:
|
||||
"""Return local image URL if images exist, else None."""
|
||||
if self.images and len(self.images) > 0:
|
||||
return f"/api/equipment/{self.id}/image"
|
||||
return None
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_equipment_category", "category"),
|
||||
Index("idx_equipment_name", "name"),
|
||||
Index("idx_equipment_number", "number"),
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Rental request and rental request item models."""
|
||||
from sqlalchemy import Column, Integer, String, Text, Date, TIMESTAMP, ForeignKey, func, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class RentalRequest(Base):
|
||||
__tablename__ = "rental_requests"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
reference_number = Column(String(32), unique=True, nullable=False)
|
||||
event_name = Column(String(255), nullable=False)
|
||||
date_start = Column(Date, nullable=False)
|
||||
date_end = Column(Date, nullable=False)
|
||||
location = Column(String(255))
|
||||
person_count = Column(Integer)
|
||||
contact_name = Column(String(255), nullable=False)
|
||||
contact_company = Column(String(255))
|
||||
contact_email = Column(String(255), nullable=False)
|
||||
contact_phone = Column(String(64))
|
||||
contact_street = Column(String(255))
|
||||
contact_postalcode = Column(String(16))
|
||||
contact_city = Column(String(128))
|
||||
message = Column(Text)
|
||||
status = Column(String(32), default="pending")
|
||||
rentman_request_id = Column(String(64))
|
||||
rentman_sync_status = Column(String(32), default="pending")
|
||||
rentman_sync_error = Column(Text)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
items = relationship("RentalRequestItem", back_populates="rental_request", cascade="all, delete-orphan")
|
||||
|
||||
__table_args__ = (Index("idx_rental_status", "status"),)
|
||||
|
||||
|
||||
class RentalRequestItem(Base):
|
||||
__tablename__ = "rental_request_items"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
rental_request_id = Column(Integer, ForeignKey("rental_requests.id", ondelete="CASCADE"), nullable=False)
|
||||
equipment_id = Column(Integer, ForeignKey("equipment_cache.id"))
|
||||
equipment_name = Column(String(255))
|
||||
rentman_equipment_id = Column(String(64))
|
||||
quantity = Column(Integer, nullable=False, default=1)
|
||||
unit_price = Column(String(20))
|
||||
rentman_sync_status = Column(String(32), default="pending")
|
||||
|
||||
rental_request = relationship("RentalRequest", back_populates="items")
|
||||
|
||||
__table_args__ = (Index("idx_rental_items_request", "rental_request_id"),)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Sync log model for equipment import tracking."""
|
||||
from sqlalchemy import Column, Integer, String, Text, TIMESTAMP, func, Index
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class SyncLog(Base):
|
||||
__tablename__ = "sync_log"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
sync_type = Column(String(32), nullable=False, default="equipment")
|
||||
status = Column(String(32), nullable=False)
|
||||
items_processed = Column(Integer, default=0)
|
||||
items_failed = Column(Integer, default=0)
|
||||
error_message = Column(Text)
|
||||
started_at = Column(TIMESTAMP, server_default=func.now())
|
||||
completed_at = Column(TIMESTAMP)
|
||||
|
||||
__table_args__ = (Index("idx_sync_log_started", "started_at"),)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
"""Admin router: login, sync endpoints, sync log."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Any
|
||||
from app.database import get_db
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.sync_log import SyncLog
|
||||
from app.schemas.auth import LoginRequest, TokenResponse, AdminInfo
|
||||
from app.schemas.sync import SyncStatus, SyncLogEntry, SyncTriggerResponse
|
||||
from app.auth import verify_password, create_access_token, get_current_user
|
||||
from app.services.sync_service import SyncService
|
||||
from app.cache import cache
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(
|
||||
creds: LoginRequest,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""Admin login: verify credentials, set JWT in HttpOnly cookie."""
|
||||
# Rate limiting
|
||||
rate_key = f"rate:login:{creds.username}"
|
||||
count = await cache.incr_rate(rate_key, window=60)
|
||||
if count > 5:
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many login attempts")
|
||||
|
||||
result = await db.execute(select(AdminUser).where(AdminUser.username == creds.username))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not verify_password(creds.password, user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
|
||||
token = create_access_token({"sub": user.username})
|
||||
response.set_cookie(
|
||||
key="hms_admin_token",
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="strict",
|
||||
max_age=86400,
|
||||
path="/",
|
||||
)
|
||||
return TokenResponse(access_token=token, token_type="bearer")
|
||||
|
||||
|
||||
@router.get("/me", response_model=AdminInfo)
|
||||
async def get_me(user: AdminUser = Depends(get_current_user)) -> Any:
|
||||
"""Return current authenticated admin user."""
|
||||
return AdminInfo(username=user.username)
|
||||
|
||||
|
||||
@router.post("/sync", response_model=SyncTriggerResponse)
|
||||
async def trigger_sync(
|
||||
user: AdminUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""Trigger a manual equipment sync (admin only)."""
|
||||
sync_service = SyncService(db)
|
||||
result = await sync_service.run_sync()
|
||||
return SyncTriggerResponse(sync_id=result["sync_id"], status=result["status"])
|
||||
|
||||
|
||||
@router.get("/sync-status", response_model=SyncStatus)
|
||||
async def sync_status(
|
||||
user: AdminUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""Return the latest sync status."""
|
||||
sync_service = SyncService(db)
|
||||
return await sync_service.get_last_sync()
|
||||
|
||||
|
||||
@router.get("/sync-log")
|
||||
async def sync_log(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: AdminUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""Return paginated sync log entries (admin only)."""
|
||||
sync_service = SyncService(db)
|
||||
result = await sync_service.get_sync_log_paginated(page=page, page_size=page_size)
|
||||
return {
|
||||
"items": [SyncLogEntry.model_validate(log) for log in result["items"]],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
"total_pages": result["total_pages"],
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Contact form router."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.models.contact import Contact
|
||||
from app.schemas.contact import ContactCreate, ContactResponse
|
||||
from app.services.email_service import EmailService
|
||||
from app.cache import cache
|
||||
|
||||
router = APIRouter(prefix="/api/contact", tags=["contact"])
|
||||
|
||||
|
||||
@router.post("", response_model=ContactResponse)
|
||||
async def create_contact(
|
||||
payload: ContactCreate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ContactResponse:
|
||||
"""Save contact form and send email."""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
rate_key = f"rate:contact:{client_ip}"
|
||||
count = await cache.incr_rate(rate_key, window=60)
|
||||
if count > 5:
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many requests")
|
||||
|
||||
contact = Contact(
|
||||
name=payload.name,
|
||||
email=str(payload.email),
|
||||
phone=payload.phone,
|
||||
message=payload.message,
|
||||
privacy_consent=payload.privacy_consent,
|
||||
)
|
||||
db.add(contact)
|
||||
await db.commit()
|
||||
|
||||
email_service = EmailService()
|
||||
try:
|
||||
sent = await email_service.send_contact_email({
|
||||
"name": payload.name,
|
||||
"email": str(payload.email),
|
||||
"phone": payload.phone,
|
||||
"message": payload.message,
|
||||
}, db=db)
|
||||
if sent:
|
||||
contact.email_sent = True
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ContactResponse(success=True)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Equipment API router: list, detail, categories, image."""
|
||||
import os
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Any
|
||||
from app.database import get_db
|
||||
from app.models.equipment import EquipmentCache
|
||||
from app.schemas.equipment import EquipmentItem, EquipmentDetail, PaginatedResponse
|
||||
from app.cache import cache
|
||||
|
||||
router = APIRouter(prefix="/api/equipment", tags=["equipment"])
|
||||
|
||||
IMAGES_DIR = "/data/images/equipment"
|
||||
|
||||
|
||||
@router.get("", response_model=PaginatedResponse)
|
||||
async def list_equipment(
|
||||
search: str | None = Query(None),
|
||||
category: str | None = Query(None),
|
||||
sort: str | None = Query("name_asc"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""Return paginated equipment list with optional search/filter/sort."""
|
||||
cache_key = f"equipment:list:{search}:{category}:{sort}:{page}:{page_size}"
|
||||
cached = await cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
query = select(EquipmentCache)
|
||||
count_query = select(func.count(EquipmentCache.id))
|
||||
|
||||
if search:
|
||||
query = query.where(EquipmentCache.name.ilike(f"%{search}%"))
|
||||
count_query = count_query.where(EquipmentCache.name.ilike(f"%{search}%"))
|
||||
if category:
|
||||
query = query.where(EquipmentCache.category == category)
|
||||
count_query = count_query.where(EquipmentCache.category == category)
|
||||
|
||||
if sort == "name_desc":
|
||||
query = query.order_by(EquipmentCache.name.desc())
|
||||
else:
|
||||
query = query.order_by(EquipmentCache.name.asc())
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(query.offset(offset).limit(page_size))
|
||||
items = result.scalars().all()
|
||||
|
||||
response = {
|
||||
"items": [EquipmentItem.model_validate(item).model_dump() for item in items],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
|
||||
}
|
||||
await cache.set(cache_key, response, ttl=3600)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/categories", response_model=list[str])
|
||||
async def list_categories(db: AsyncSession = Depends(get_db)) -> Any:
|
||||
"""Return all distinct equipment categories."""
|
||||
cached = await cache.get("equipment:categories")
|
||||
if cached:
|
||||
return cached
|
||||
result = await db.execute(
|
||||
select(EquipmentCache.category).distinct().where(EquipmentCache.category.isnot(None))
|
||||
)
|
||||
categories = [row[0] for row in result.fetchall() if row[0]]
|
||||
await cache.set("equipment:categories", categories, ttl=3600)
|
||||
return categories
|
||||
|
||||
|
||||
@router.get("/{equipment_id}/image")
|
||||
async def get_equipment_image(equipment_id: int, db: AsyncSession = Depends(get_db)) -> Any:
|
||||
"""Serve the locally stored image for an equipment item."""
|
||||
image_path = os.path.join(IMAGES_DIR, f"{equipment_id}.jpg")
|
||||
if os.path.exists(image_path):
|
||||
return FileResponse(image_path, media_type="image/jpeg")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found")
|
||||
|
||||
|
||||
@router.get("/{equipment_id}", response_model=EquipmentDetail)
|
||||
async def get_equipment(equipment_id: int, db: AsyncSession = Depends(get_db)) -> Any:
|
||||
"""Return a single equipment detail by ID."""
|
||||
cache_key = f"equipment:detail:{equipment_id}"
|
||||
cached = await cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
result = await db.execute(select(EquipmentCache).where(EquipmentCache.id == equipment_id))
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found")
|
||||
response = EquipmentDetail.model_validate(item).model_dump()
|
||||
await cache.set(cache_key, response, ttl=3600)
|
||||
return response
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Health check router."""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db, engine
|
||||
from app.cache import cache
|
||||
|
||||
router = APIRouter(prefix="/api/health", tags=["health"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def health_check(db: AsyncSession = Depends(get_db)) -> dict:
|
||||
"""Return health status: app, db, redis."""
|
||||
db_ok = "connected"
|
||||
redis_ok = "connected"
|
||||
try:
|
||||
await db.execute(text("SELECT 1"))
|
||||
except Exception:
|
||||
db_ok = "disconnected"
|
||||
try:
|
||||
await cache.connect()
|
||||
await cache._redis.ping()
|
||||
except Exception:
|
||||
redis_ok = "disconnected"
|
||||
return {"status": "ok", "db": db_ok, "redis": redis_ok}
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Rental request router: accept Mietanfrage, save, forward to Rentman."""
|
||||
import random
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Any
|
||||
from app.database import get_db
|
||||
from app.models.equipment import EquipmentCache
|
||||
from app.models.rental_request import RentalRequest, RentalRequestItem
|
||||
from app.schemas.rental_request import RentalRequestCreate, RentalRequestResponse
|
||||
from app.services.rentman_service import RentmanService
|
||||
from app.services.email_service import EmailService
|
||||
from app.cache import cache
|
||||
|
||||
router = APIRouter(prefix="/api/rental-requests", tags=["rental-requests"])
|
||||
|
||||
|
||||
def generate_reference_number() -> str:
|
||||
"""Generate a unique reference number: HMS-YYYY-NNNNN."""
|
||||
year = datetime.now().year
|
||||
num = random.randint(1, 99999)
|
||||
return f"HMS-{year}-{num:05d}"
|
||||
|
||||
|
||||
@router.post("", response_model=RentalRequestResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_rental_request(
|
||||
payload: RentalRequestCreate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""Create a rental request, save to DB, forward to Rentman, send email."""
|
||||
# Rate limiting
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
rate_key = f"rate:rental:{client_ip}"
|
||||
count = await cache.incr_rate(rate_key, window=60)
|
||||
if count > 5:
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many requests")
|
||||
|
||||
ref_number = generate_reference_number()
|
||||
|
||||
# Save to database
|
||||
rental = RentalRequest(
|
||||
reference_number=ref_number,
|
||||
event_name=payload.event_name,
|
||||
date_start=payload.date_start,
|
||||
date_end=payload.date_end,
|
||||
location=payload.location,
|
||||
person_count=payload.person_count,
|
||||
contact_name=payload.contact_name,
|
||||
contact_company=payload.contact_company,
|
||||
contact_email=str(payload.contact_email),
|
||||
contact_phone=payload.contact_phone,
|
||||
contact_street=payload.contact_street,
|
||||
contact_postalcode=payload.contact_postalcode,
|
||||
contact_city=payload.contact_city,
|
||||
message=payload.message,
|
||||
status="pending",
|
||||
rentman_sync_status="pending",
|
||||
)
|
||||
db.add(rental)
|
||||
await db.flush()
|
||||
|
||||
# Fetch equipment info for items
|
||||
items_data = []
|
||||
for item in payload.items:
|
||||
result = await db.execute(select(EquipmentCache).where(EquipmentCache.id == item.equipment_id))
|
||||
eq = result.scalar_one_or_none()
|
||||
eq_name = eq.name if eq else "Unknown"
|
||||
eq_rentman_id = eq.rentman_id if eq else ""
|
||||
|
||||
req_item = RentalRequestItem(
|
||||
rental_request_id=rental.id,
|
||||
equipment_id=item.equipment_id,
|
||||
equipment_name=eq_name,
|
||||
rentman_equipment_id=eq_rentman_id,
|
||||
quantity=item.quantity,
|
||||
rentman_sync_status="pending",
|
||||
)
|
||||
db.add(req_item)
|
||||
items_data.append({
|
||||
"equipment_name": eq_name,
|
||||
"rentman_equipment_id": eq_rentman_id,
|
||||
"quantity": item.quantity,
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Forward to Rentman
|
||||
rentman = RentmanService()
|
||||
rentman_payload = RentmanService.build_project_request_payload({
|
||||
"event_name": payload.event_name,
|
||||
"date_start": str(payload.date_start),
|
||||
"date_end": str(payload.date_end),
|
||||
"contact_name": payload.contact_name,
|
||||
"contact_company": payload.contact_company,
|
||||
"contact_email": str(payload.contact_email),
|
||||
"contact_phone": payload.contact_phone,
|
||||
"contact_street": payload.contact_street,
|
||||
"contact_postalcode": payload.contact_postalcode,
|
||||
"contact_city": payload.contact_city,
|
||||
"location": payload.location,
|
||||
"message": payload.message,
|
||||
})
|
||||
|
||||
try:
|
||||
rentman_response = await rentman.create_project_request(rentman_payload)
|
||||
rentman_request_id = str(rentman_response.get("id", ""))
|
||||
rental.rentman_request_id = rentman_request_id
|
||||
rental.rentman_sync_status = "project_created"
|
||||
|
||||
# Add equipment items to Rentman
|
||||
for item_data in items_data:
|
||||
eq_payload = RentmanService.build_equipment_payload(item_data)
|
||||
try:
|
||||
await rentman.add_equipment_to_request(rentman_request_id, eq_payload)
|
||||
except Exception:
|
||||
# Item-level failure: mark as failed but continue
|
||||
pass
|
||||
|
||||
rental.rentman_sync_status = "success"
|
||||
except Exception:
|
||||
rental.rentman_sync_status = "failed"
|
||||
rental.rentman_sync_error = "Rentman API request failed"
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Send confirmation email
|
||||
email_service = EmailService()
|
||||
try:
|
||||
await email_service.send_rental_confirmation(
|
||||
to_addr=str(payload.contact_email),
|
||||
reference_number=ref_number,
|
||||
event_name=payload.event_name,
|
||||
items=items_data,
|
||||
db=db,
|
||||
)
|
||||
except Exception:
|
||||
# Email failure should not affect response
|
||||
pass
|
||||
|
||||
return RentalRequestResponse(reference_number=ref_number, status="pending")
|
||||
@@ -0,0 +1 @@
|
||||
"""Schema exports."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
"""Pydantic schemas for admin auth."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class AdminInfo(BaseModel):
|
||||
username: str
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Pydantic schema for contact form."""
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
|
||||
|
||||
class ContactCreate(BaseModel):
|
||||
name: str
|
||||
email: EmailStr
|
||||
phone: str | None = None
|
||||
message: str
|
||||
privacy_consent: bool
|
||||
|
||||
@field_validator("privacy_consent")
|
||||
@classmethod
|
||||
def consent_must_be_true(cls, v: bool) -> bool:
|
||||
if not v:
|
||||
raise ValueError("privacy_consent must be True")
|
||||
return v
|
||||
|
||||
|
||||
class ContactResponse(BaseModel):
|
||||
success: bool
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Pydantic schemas for equipment endpoints."""
|
||||
from pydantic import BaseModel
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
|
||||
class EquipmentItem(BaseModel):
|
||||
id: int
|
||||
rentman_id: str
|
||||
name: str
|
||||
number: str | None = None
|
||||
category: str | None = None
|
||||
description: str | None = None
|
||||
image_url: str | None = None
|
||||
rental_price: Decimal | None = None
|
||||
available: bool = True
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class EquipmentDetail(BaseModel):
|
||||
id: int
|
||||
rentman_id: str
|
||||
name: str
|
||||
number: str | None = None
|
||||
category: str | None = None
|
||||
description: str | None = None
|
||||
specifications: dict[str, Any] | None = None
|
||||
images: list[str] | None = None
|
||||
rental_price: Decimal | None = None
|
||||
brand: str | None = None
|
||||
available: bool = True
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel):
|
||||
items: list[EquipmentItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Pydantic schemas for rental requests."""
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
from datetime import date
|
||||
|
||||
|
||||
class RentalRequestItemCreate(BaseModel):
|
||||
equipment_id: int
|
||||
quantity: int = 1
|
||||
|
||||
@field_validator("quantity")
|
||||
@classmethod
|
||||
def quantity_positive(cls, v: int) -> int:
|
||||
if v < 1:
|
||||
raise ValueError("quantity must be >= 1")
|
||||
return v
|
||||
|
||||
|
||||
class RentalRequestCreate(BaseModel):
|
||||
event_name: str
|
||||
date_start: date
|
||||
date_end: date
|
||||
location: str | None = None
|
||||
person_count: int | None = None
|
||||
contact_name: str
|
||||
contact_company: str | None = None
|
||||
contact_email: EmailStr
|
||||
contact_phone: str | None = None
|
||||
contact_street: str | None = None
|
||||
contact_postalcode: str | None = None
|
||||
contact_city: str | None = None
|
||||
message: str | None = None
|
||||
items: list[RentalRequestItemCreate]
|
||||
|
||||
@field_validator("date_end")
|
||||
@classmethod
|
||||
def date_end_after_start(cls, v: date, values) -> date:
|
||||
start = values.data.get("date_start")
|
||||
if start and v < start:
|
||||
raise ValueError("date_end must be >= date_start")
|
||||
return v
|
||||
|
||||
@field_validator("items")
|
||||
@classmethod
|
||||
def items_not_empty(cls, v: list) -> list:
|
||||
if len(v) == 0:
|
||||
raise ValueError("items must not be empty")
|
||||
return v
|
||||
|
||||
|
||||
class RentalRequestResponse(BaseModel):
|
||||
reference_number: str
|
||||
status: str
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Pydantic schemas for sync status and log."""
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SyncStatus(BaseModel):
|
||||
last_sync: datetime | None = None
|
||||
items_processed: int = 0
|
||||
status: str = "never"
|
||||
|
||||
|
||||
class SyncLogEntry(BaseModel):
|
||||
id: int
|
||||
sync_type: str
|
||||
status: str
|
||||
items_processed: int
|
||||
items_failed: int
|
||||
error_message: str | None = None
|
||||
started_at: datetime
|
||||
completed_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SyncTriggerResponse(BaseModel):
|
||||
sync_id: int
|
||||
status: str
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,186 @@
|
||||
"""Email service using aiosmtplib for contact and rental confirmation emails.
|
||||
|
||||
Includes a failed-email queue: when SMTP fails and a db session is provided,
|
||||
the email is persisted to the email_queue table for later retry via APScheduler.
|
||||
"""
|
||||
import logging
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from typing import Any
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import aiosmtplib
|
||||
from app.config import get_settings
|
||||
from app.models.email_queue import EmailQueue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""Send transactional emails via SMTP with failed-email queue support."""
|
||||
|
||||
async def send_email(
|
||||
self,
|
||||
to_addr: str,
|
||||
subject: str,
|
||||
html_body: str,
|
||||
text_body: str = "",
|
||||
reply_to: str | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> bool:
|
||||
"""Send an email via aiosmtplib. Returns True on success.
|
||||
|
||||
If SMTP fails and a db session is provided, the email is saved to
|
||||
the email_queue table for later retry.
|
||||
"""
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = settings.smtp_from
|
||||
msg["To"] = to_addr
|
||||
msg["Subject"] = subject
|
||||
if reply_to:
|
||||
msg["Reply-To"] = reply_to
|
||||
msg.attach(MIMEText(text_body or html_body, "plain"))
|
||||
msg.attach(MIMEText(html_body, "html"))
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=settings.smtp_host,
|
||||
port=settings.smtp_port,
|
||||
username=settings.smtp_user,
|
||||
password=settings.smtp_password,
|
||||
start_tls=True,
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Email send failed to %s: %s", to_addr, exc)
|
||||
if db is not None:
|
||||
try:
|
||||
queued = EmailQueue(
|
||||
to_addr=to_addr,
|
||||
subject=subject,
|
||||
html_body=html_body,
|
||||
text_body=text_body,
|
||||
reply_to=reply_to,
|
||||
status="pending",
|
||||
attempts=0,
|
||||
)
|
||||
db.add(queued)
|
||||
await db.commit()
|
||||
logger.info("Email queued for retry: %s", to_addr)
|
||||
except Exception as queue_exc:
|
||||
logger.error("Failed to queue email: %s", queue_exc)
|
||||
return False
|
||||
|
||||
async def send_contact_email(
|
||||
self, contact_data: dict[str, Any], db: AsyncSession | None = None
|
||||
) -> bool:
|
||||
"""Send contact form data to info@hms-licht-ton.de."""
|
||||
html = f"""
|
||||
<h2>Neue Kontaktanfrage</h2>
|
||||
<p><strong>Name:</strong> {contact_data.get('name', '')}</p>
|
||||
<p><strong>Email:</strong> {contact_data.get('email', '')}</p>
|
||||
<p><strong>Telefon:</strong> {contact_data.get('phone', '')}</p>
|
||||
<p><strong>Nachricht:</strong></p>
|
||||
<p>{contact_data.get('message', '')}</p>
|
||||
"""
|
||||
text = (
|
||||
f"Neue Kontaktanfrage\n"
|
||||
f"Name: {contact_data.get('name', '')}\n"
|
||||
f"Email: {contact_data.get('email', '')}\n"
|
||||
f"Telefon: {contact_data.get('phone', '')}\n"
|
||||
f"Nachricht: {contact_data.get('message', '')}\n"
|
||||
)
|
||||
return await self.send_email(
|
||||
to_addr=settings.smtp_from,
|
||||
subject="Neue Kontaktanfrage ueber hms-licht-ton.de",
|
||||
html_body=html,
|
||||
text_body=text,
|
||||
reply_to=contact_data.get("email"),
|
||||
db=db,
|
||||
)
|
||||
|
||||
async def send_rental_confirmation(
|
||||
self,
|
||||
to_addr: str,
|
||||
reference_number: str,
|
||||
event_name: str,
|
||||
items: list[dict[str, Any]],
|
||||
db: AsyncSession | None = None,
|
||||
) -> bool:
|
||||
"""Send rental request confirmation to customer."""
|
||||
items_html = "".join(
|
||||
f"<li>{item.get('equipment_name', 'Unbekannt')} - Menge: {item.get('quantity', 1)}</li>"
|
||||
for item in items
|
||||
)
|
||||
html = f"""
|
||||
<h2>Mietanfrage bestätigt – {reference_number}</h2>
|
||||
<p>Vielen Dank für Ihre Mietanfrage bei HMS Licht & Ton.</p>
|
||||
<p><strong>Veranstaltung:</strong> {event_name}</p>
|
||||
<p><strong>Referenznummer:</strong> {reference_number}</p>
|
||||
<h3>Artikel:</h3>
|
||||
<ul>{items_html}</ul>
|
||||
<p>Wir melden uns in Kürze bei Ihnen.</p>
|
||||
<p>Ihr HMS Licht & Ton Team</p>
|
||||
"""
|
||||
text = (
|
||||
f"Mietanfrage bestaetigt - {reference_number}\n"
|
||||
f"Veranstaltung: {event_name}\n"
|
||||
f"Referenznummer: {reference_number}\n"
|
||||
f"Artikel:\n"
|
||||
+ "\n".join(
|
||||
f"- {item.get('equipment_name', 'Unbekannt')}: {item.get('quantity', 1)}"
|
||||
for item in items
|
||||
)
|
||||
)
|
||||
return await self.send_email(
|
||||
to_addr=to_addr,
|
||||
subject=f"Mietanfrage bestaetigt – {reference_number}",
|
||||
html_body=html,
|
||||
text_body=text,
|
||||
db=db,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def retry_failed_emails(db: AsyncSession, max_attempts: int = 5) -> int:
|
||||
"""Retry all pending emails in the queue. Returns count of successfully sent emails."""
|
||||
result = await db.execute(
|
||||
select(EmailQueue).where(
|
||||
EmailQueue.status == "pending",
|
||||
EmailQueue.attempts < max_attempts,
|
||||
)
|
||||
)
|
||||
pending = result.scalars().all()
|
||||
sent_count = 0
|
||||
|
||||
for item in pending:
|
||||
item.attempts += 1
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = settings.smtp_from
|
||||
msg["To"] = item.to_addr
|
||||
msg["Subject"] = item.subject
|
||||
if item.reply_to:
|
||||
msg["Reply-To"] = item.reply_to
|
||||
msg.attach(MIMEText(item.text_body or item.html_body, "plain"))
|
||||
msg.attach(MIMEText(item.html_body, "html"))
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=settings.smtp_host,
|
||||
port=settings.smtp_port,
|
||||
username=settings.smtp_user,
|
||||
password=settings.smtp_password,
|
||||
start_tls=True,
|
||||
)
|
||||
item.status = "sent"
|
||||
sent_count += 1
|
||||
logger.info("Retry succeeded for email to %s", item.to_addr)
|
||||
except Exception as exc:
|
||||
logger.error("Retry failed for email to %s: %s", item.to_addr, exc)
|
||||
if item.attempts >= max_attempts:
|
||||
item.status = "failed"
|
||||
|
||||
await db.commit()
|
||||
return sent_count
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Rentman API client wrapper for equipment import and request submission."""
|
||||
import httpx
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
RENTMAN_BASE_URL = "https://api.rentman.net"
|
||||
|
||||
|
||||
class RentmanService:
|
||||
"""Wrapper around the Rentman REST API using httpx."""
|
||||
|
||||
def __init__(self, token: str | None = None) -> None:
|
||||
self._token = token or settings.rentman_api_token
|
||||
self._base_url = RENTMAN_BASE_URL
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def get_equipment_page(self, limit: int = 100, offset: int = 0) -> dict[str, Any]:
|
||||
"""Fetch a single page of equipment from Rentman.
|
||||
|
||||
Returns the raw JSON response dict with 'data' and optional 'itemCount'.
|
||||
"""
|
||||
url = f"{self._base_url}/equipment"
|
||||
params = {"limit": limit, "offset": offset}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.get(url, headers=self._headers(), params=params)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_all_equipment(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||
"""Paginate through all equipment pages until data is empty."""
|
||||
all_items: list[dict[str, Any]] = []
|
||||
offset = 0
|
||||
while True:
|
||||
page = await self.get_equipment_page(limit=limit, offset=offset)
|
||||
data = page.get("data", [])
|
||||
if not data:
|
||||
break
|
||||
all_items.extend(data)
|
||||
offset += limit
|
||||
return all_items
|
||||
|
||||
async def get_file_url(self, file_id: str | int) -> str | None:
|
||||
"""Fetch the S3 URL for a file from Rentman.
|
||||
|
||||
GET /files/{file_id} → response contains 'url' field with S3 link.
|
||||
"""
|
||||
url = f"{self._base_url}/files/{file_id}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.get(url, headers=self._headers())
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
file_data = data.get("data", data)
|
||||
file_url = file_data.get("url")
|
||||
if file_url:
|
||||
return file_url
|
||||
logger.warning("No url field in file response for file_id=%s", file_id)
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch file URL for file_id=%s: %s", file_id, exc)
|
||||
return None
|
||||
|
||||
async def transform_equipment(self, raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map a raw Rentman equipment object to equipment_cache schema.
|
||||
|
||||
Uses 'image' (singular) field which contains a relative path like '/files/3173'.
|
||||
Fetches the actual S3 URL via GET /files/{file_id}.
|
||||
"""
|
||||
image_path = raw.get("image")
|
||||
image_urls: list[str] = []
|
||||
|
||||
if image_path and isinstance(image_path, str):
|
||||
# Extract file_id from path like /files/3173
|
||||
match = re.search(r"/files/(\d+)", image_path)
|
||||
if match:
|
||||
file_id = match.group(1)
|
||||
s3_url = await self.get_file_url(file_id)
|
||||
if s3_url:
|
||||
image_urls = [s3_url]
|
||||
else:
|
||||
logger.debug("Could not extract file_id from image path: %s", image_path)
|
||||
|
||||
group = raw.get("equipment_group") or {}
|
||||
category = group.get("name", "") if isinstance(group, dict) else str(group or "")
|
||||
|
||||
return {
|
||||
"rentman_id": str(raw.get("id", "")),
|
||||
"name": raw.get("name", ""),
|
||||
"number": raw.get("number") or raw.get("code", ""),
|
||||
"category": category,
|
||||
"subcategory": raw.get("subcategory", ""),
|
||||
"description": raw.get("description", ""),
|
||||
"specifications": raw.get("specifications", {}),
|
||||
"images": image_urls,
|
||||
"rental_price": raw.get("rental_price"),
|
||||
"brand": raw.get("brand", ""),
|
||||
"available": raw.get("available", True),
|
||||
"update_hash": raw.get("updateHash", ""),
|
||||
}
|
||||
|
||||
async def create_project_request(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""POST /projectrequests to create a new project request in Rentman."""
|
||||
url = f"{self._base_url}/projectrequests"
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(url, headers=self._headers(), json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def add_equipment_to_request(
|
||||
self, request_id: str, equipment_payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""POST /projectrequests/{id}/projectrequestequipment for a single item."""
|
||||
url = f"{self._base_url}/projectrequests/{request_id}/projectrequestequipment"
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(url, headers=self._headers(), json=equipment_payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
@staticmethod
|
||||
def build_project_request_payload(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map frontend rental request data to Rentman POST /projectrequests payload."""
|
||||
date_start = data.get("date_start")
|
||||
date_end = data.get("date_end")
|
||||
|
||||
contact_name = data.get("contact_name", "")
|
||||
parts = contact_name.strip().split(" ", 1)
|
||||
first_name = parts[0] if parts else ""
|
||||
last_name = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
street = data.get("contact_street", "")
|
||||
house_number = ""
|
||||
if street:
|
||||
match = re.search(r"(\d+[a-zA-Z]*)", street)
|
||||
if match:
|
||||
house_number = match.group(1)
|
||||
|
||||
return {
|
||||
"name": data.get("event_name", ""),
|
||||
"planperiod_start": f"{date_start}T08:00:00+02:00" if date_start else None,
|
||||
"planperiod_end": f"{date_end}T02:00:00+02:00" if date_end else None,
|
||||
"usageperiod_start": f"{date_start}T18:00:00+02:00" if date_start else None,
|
||||
"usageperiod_end": f"{date_end}T23:59:00+02:00" if date_end else None,
|
||||
"contact_name": data.get("contact_company") or data.get("contact_name", ""),
|
||||
"contact_person_first_name": first_name,
|
||||
"contact_person_lastname": last_name,
|
||||
"contact_person_email": data.get("contact_email", ""),
|
||||
"contact_person_phone": data.get("contact_phone", ""),
|
||||
"location_name": data.get("location", ""),
|
||||
"location_mailing_street": street,
|
||||
"location_mailing_number": house_number,
|
||||
"location_mailing_postalcode": data.get("contact_postalcode", ""),
|
||||
"location_mailing_city": data.get("contact_city") or data.get("location", ""),
|
||||
"remark": data.get("message", ""),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def build_equipment_payload(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map a single rental request item to Rentman projectrequestequipment payload."""
|
||||
return {
|
||||
"name": item.get("equipment_name", ""),
|
||||
"quantity": item.get("quantity", 1),
|
||||
"quantity_total": item.get("quantity", 1),
|
||||
"unit_price": item.get("unit_price", 0),
|
||||
"linked_equipment": f"/equipment/{item.get('rentman_equipment_id', '')}" if item.get("rentman_equipment_id") else None,
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Equipment sync service: import from Rentman, upsert into DB, invalidate cache."""
|
||||
import logging
|
||||
import os
|
||||
import httpx
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.equipment import EquipmentCache
|
||||
from app.models.sync_log import SyncLog
|
||||
from app.services.rentman_service import RentmanService
|
||||
from app.cache import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IMAGES_DIR = "/data/images/equipment"
|
||||
|
||||
|
||||
class SyncService:
|
||||
"""Orchestrates equipment import from Rentman into the local database."""
|
||||
|
||||
def __init__(self, db: AsyncSession, rentman: RentmanService | None = None) -> None:
|
||||
self.db = db
|
||||
self.rentman = rentman or RentmanService()
|
||||
|
||||
async def run_sync(self) -> dict[str, Any]:
|
||||
"""Execute an incremental equipment sync.
|
||||
|
||||
1. Create sync_log entry (status=running)
|
||||
2. Paginate GET /equipment from Rentman
|
||||
3. Compare updateHash with DB, only process changed items
|
||||
4. Download images only for changed items
|
||||
5. Mark missing items as unavailable
|
||||
6. Invalidate Redis cache (equipment:*)
|
||||
7. Update sync_log (status=completed or failed)
|
||||
Returns dict with sync_id, items_processed, status.
|
||||
"""
|
||||
log_entry = SyncLog(
|
||||
sync_type="equipment",
|
||||
status="running",
|
||||
started_at=datetime.utcnow(),
|
||||
)
|
||||
self.db.add(log_entry)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(log_entry)
|
||||
sync_id = log_entry.id
|
||||
|
||||
items_processed = 0
|
||||
items_failed = 0
|
||||
error_message: str | None = None
|
||||
|
||||
try:
|
||||
# Ensure images directory exists
|
||||
os.makedirs(IMAGES_DIR, exist_ok=True)
|
||||
|
||||
# Fetch all equipment from Rentman
|
||||
all_equipment = await self.rentman.get_all_equipment(limit=100)
|
||||
|
||||
# Build set of rentman_ids from API for availability check
|
||||
api_rentman_ids = set()
|
||||
|
||||
# Load all existing equipment from DB for hash comparison
|
||||
result = await self.db.execute(select(EquipmentCache))
|
||||
existing_items = {item.rentman_id: item for item in result.scalars().all()}
|
||||
|
||||
for raw_item in all_equipment:
|
||||
rentman_id = str(raw_item.get("id", ""))
|
||||
api_rentman_ids.add(rentman_id)
|
||||
new_hash = raw_item.get("updateHash", "")
|
||||
|
||||
existing = existing_items.get(rentman_id)
|
||||
|
||||
# Skip if hash unchanged and item exists
|
||||
if existing and existing.update_hash == new_hash and new_hash:
|
||||
continue
|
||||
|
||||
try:
|
||||
transformed = await self.rentman.transform_equipment(raw_item)
|
||||
await self._upsert_equipment(transformed)
|
||||
|
||||
# Download image if S3 URL available
|
||||
if transformed.get("images"):
|
||||
await self._download_image(
|
||||
transformed["rentman_id"],
|
||||
transformed["images"][0],
|
||||
existing.id if existing else None,
|
||||
)
|
||||
|
||||
items_processed += 1
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to upsert equipment item %s: %s", rentman_id, exc)
|
||||
items_failed += 1
|
||||
|
||||
# Mark missing items as unavailable
|
||||
for rentman_id, existing in existing_items.items():
|
||||
if rentman_id not in api_rentman_ids and existing.available:
|
||||
existing.available = False
|
||||
logger.info("Marked equipment %s as unavailable (not in API)", rentman_id)
|
||||
|
||||
await self.db.commit()
|
||||
|
||||
# Invalidate Redis cache
|
||||
await cache.delete_pattern("equipment:*")
|
||||
|
||||
status_val = "completed"
|
||||
except Exception as exc:
|
||||
logger.error("Equipment sync failed: %s", exc)
|
||||
error_message = str(exc)
|
||||
status_val = "failed"
|
||||
|
||||
# Update log entry
|
||||
log_entry.status = status_val
|
||||
log_entry.items_processed = items_processed
|
||||
log_entry.items_failed = items_failed
|
||||
log_entry.error_message = error_message
|
||||
log_entry.completed_at = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
|
||||
return {
|
||||
"sync_id": sync_id,
|
||||
"items_processed": items_processed,
|
||||
"items_failed": items_failed,
|
||||
"status": status_val,
|
||||
}
|
||||
|
||||
async def _upsert_equipment(self, data: dict[str, Any]) -> None:
|
||||
"""Insert or update a single equipment row by rentman_id."""
|
||||
result = await self.db.execute(
|
||||
select(EquipmentCache).where(EquipmentCache.rentman_id == data["rentman_id"])
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
existing.name = data["name"]
|
||||
existing.number = data.get("number", "")
|
||||
existing.category = data.get("category", "")
|
||||
existing.subcategory = data.get("subcategory", "")
|
||||
existing.description = data.get("description", "")
|
||||
existing.specifications = data.get("specifications")
|
||||
existing.images = data.get("images")
|
||||
existing.update_hash = data.get("update_hash")
|
||||
existing.rental_price = data.get("rental_price")
|
||||
existing.brand = data.get("brand", "")
|
||||
existing.available = data.get("available", True)
|
||||
else:
|
||||
new_item = EquipmentCache(
|
||||
rentman_id=data["rentman_id"],
|
||||
name=data["name"],
|
||||
number=data.get("number", ""),
|
||||
category=data.get("category", ""),
|
||||
subcategory=data.get("subcategory", ""),
|
||||
description=data.get("description", ""),
|
||||
specifications=data.get("specifications"),
|
||||
images=data.get("images"),
|
||||
update_hash=data.get("update_hash"),
|
||||
rental_price=data.get("rental_price"),
|
||||
brand=data.get("brand", ""),
|
||||
available=data.get("available", True),
|
||||
)
|
||||
self.db.add(new_item)
|
||||
|
||||
async def _download_image(self, rentman_id: str, s3_url: str, db_id: int | None) -> None:
|
||||
"""Download image from S3 URL and save locally.
|
||||
|
||||
File path: /data/images/equipment/{db_id or rentman_id}.jpg
|
||||
"""
|
||||
# Determine the file identifier - prefer DB id if available
|
||||
# We need to flush to get the ID for new items
|
||||
if db_id is None:
|
||||
await self.db.flush()
|
||||
result = await self.db.execute(
|
||||
select(EquipmentCache).where(EquipmentCache.rentman_id == rentman_id)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
if item:
|
||||
db_id = item.id
|
||||
|
||||
if db_id is None:
|
||||
logger.warning("Could not determine DB id for image download: %s", rentman_id)
|
||||
return
|
||||
|
||||
image_path = os.path.join(IMAGES_DIR, f"{db_id}.jpg")
|
||||
|
||||
# Skip if already exists
|
||||
if os.path.exists(image_path):
|
||||
return
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.get(s3_url)
|
||||
resp.raise_for_status()
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
logger.info("Downloaded image for equipment %s -> %s", db_id, image_path)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to download image for %s: %s", rentman_id, exc)
|
||||
|
||||
async def get_last_sync(self) -> dict[str, Any]:
|
||||
"""Return the most recent sync_log entry summary."""
|
||||
result = await self.db.execute(
|
||||
select(SyncLog).order_by(SyncLog.started_at.desc()).limit(1)
|
||||
)
|
||||
log = result.scalar_one_or_none()
|
||||
if not log:
|
||||
return {"last_sync": None, "items_processed": 0, "status": "never"}
|
||||
return {
|
||||
"last_sync": log.started_at,
|
||||
"items_processed": log.items_processed,
|
||||
"status": log.status,
|
||||
}
|
||||
|
||||
async def get_sync_log_paginated(self, page: int = 1, page_size: int = 20) -> dict[str, Any]:
|
||||
"""Return paginated sync log entries."""
|
||||
offset = (page - 1) * page_size
|
||||
result = await self.db.execute(
|
||||
select(SyncLog)
|
||||
.order_by(SyncLog.started_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
logs = result.scalars().all()
|
||||
count_result = await self.db.execute(select(SyncLog))
|
||||
total = len(count_result.scalars().all())
|
||||
return {
|
||||
"items": logs,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
@@ -0,0 +1,19 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
sqlalchemy[asyncio]>=2.0.30
|
||||
asyncpg>=0.29.0
|
||||
aiosqlite>=0.20.0
|
||||
redis>=5.0.0
|
||||
pydantic[email]>=2.7.0
|
||||
pydantic-settings>=2.3.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
httpx>=0.27.0
|
||||
aiosmtplib>=3.0.0
|
||||
apscheduler>=3.10.0
|
||||
python-multipart>=0.0.9
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.23.0
|
||||
pytest-cov>=5.0.0
|
||||
httpx>=0.27.0
|
||||
mcp>=1.0.0
|
||||
@@ -0,0 +1,64 @@
|
||||
# Test Report – T05: Email Service + Contact & Rental Request Backend
|
||||
|
||||
## Test Commands
|
||||
|
||||
### Targeted T05 Tests
|
||||
```bash
|
||||
python -m pytest tests/test_contact_router.py tests/test_rental_router.py tests/test_email_service.py tests/test_rate_limiting.py -v
|
||||
```
|
||||
**Result: 23 passed in 0.81s**
|
||||
|
||||
### Full Suite with Coverage
|
||||
```bash
|
||||
python -m pytest tests/ -v --cov=app --cov-report=term-missing
|
||||
```
|
||||
**Result: 57 passed, 4 warnings in 7.31s**
|
||||
**Coverage: 78% overall**
|
||||
|
||||
## Test Files Created
|
||||
|
||||
### tests/test_contact_router.py (6 tests)
|
||||
- test_contact_valid_returns_200 – POST /api/contact valid → 200, success=True
|
||||
- test_contact_invalid_email_returns_422 – invalid email → 422
|
||||
- test_contact_privacy_consent_false_returns_422 – privacy_consent=false → 422
|
||||
- test_contact_triggers_email – mock EmailService.send_contact_email called with correct data
|
||||
- test_contact_saved_to_db – Contact record saved with email_sent=True
|
||||
- test_contact_email_failure_does_not_break_response – email failure still returns 200
|
||||
|
||||
### tests/test_rental_router.py (7 tests)
|
||||
- test_rental_valid_returns_201 – valid POST → 201 with HMS-YYYY-NNNNN reference
|
||||
- test_rental_date_end_before_start_returns_422 – date_end < date_start → 422
|
||||
- test_rental_empty_items_returns_422 – empty items array → 422
|
||||
- test_rental_saved_to_db – RentalRequest + RentalRequestItem saved, rentman_sync_status=success
|
||||
- test_rental_triggers_rentman – RentmanService.create_project_request + add_equipment_to_request called
|
||||
- test_rental_triggers_email – EmailService.send_rental_confirmation called with ref + items
|
||||
- test_rental_rentman_failure_still_returns_201 – Rentman failure still returns 201
|
||||
|
||||
### tests/test_email_service.py (7 tests)
|
||||
- test_send_contact_email_success – aiosmtplib.send called, MIMEMultipart contains name + message
|
||||
- test_send_rental_confirmation_success – email contains reference_number + items
|
||||
- test_send_email_smtp_failure_returns_false – SMTP exception → returns False, no crash
|
||||
- test_send_contact_email_smtp_failure_returns_false – contact email SMTP failure → False
|
||||
- test_send_rental_confirmation_smtp_failure_returns_false – rental email SMTP failure → False
|
||||
- test_send_email_failure_saves_to_queue – failed email saved to email_queue table
|
||||
- test_retry_failed_emails_resends_pending – retry sends queued email, marks as sent
|
||||
|
||||
### tests/test_rate_limiting.py (3 tests)
|
||||
- test_contact_rate_limit_allows_5_blocks_6th – 5 requests OK, 6th → 429
|
||||
- test_rental_rate_limit_allows_5_blocks_6th – 5 requests OK, 6th → 429
|
||||
- test_contact_rate_window_reset – rate window reset allows new requests after TTL
|
||||
|
||||
## New Feature: Email Retry Queue
|
||||
- `app/models/email_queue.py` – EmailQueue model (id, to_addr, subject, html_body, text_body, reply_to, status, attempts, created_at, updated_at)
|
||||
- `app/services/email_service.py` – send_email now accepts optional `db` param; on SMTP failure saves to queue
|
||||
- `EmailService.retry_failed_emails(db)` – static method to retry pending emails (max 5 attempts)
|
||||
- `app/main.py` – APScheduler job `run_email_retry` every 15 minutes
|
||||
- Routers updated to pass `db` to email service methods
|
||||
|
||||
## Smoke Test
|
||||
- All 57 tests pass (23 new T05 + 34 existing)
|
||||
- No existing tests broken
|
||||
- Coverage: 78% overall, email_service 90%, contact.py 67%, rental_requests.py 46%
|
||||
- Rate limiting works via mock cache.incr_rate with sequential return values
|
||||
- Email queue persistence verified with in-memory SQLite
|
||||
- Retry mechanism verified: pending → sent on success
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,107 @@
|
||||
"""Pytest fixtures: test database, client, mock Redis, mock admin user."""
|
||||
import asyncio
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.cache import cache
|
||||
from app.models import EquipmentCache, RentalRequest, RentalRequestItem, Contact, AdminUser, SyncLog
|
||||
from app.auth import get_password_hash
|
||||
|
||||
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_engine():
|
||||
"""Create an in-memory SQLite engine for tests."""
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_db(test_engine):
|
||||
"""Yield a test database session."""
|
||||
session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with session_maker() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def client(test_engine):
|
||||
"""Yield an async test client with test database and mock cache."""
|
||||
session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async def override_get_db():
|
||||
async with session_maker() as session:
|
||||
yield session
|
||||
|
||||
# Mock cache to avoid Redis dependency
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.get = AsyncMock(return_value=None)
|
||||
mock_cache.set = AsyncMock(return_value=None)
|
||||
mock_cache.delete_pattern = AsyncMock(return_value=0)
|
||||
mock_cache.incr_rate = AsyncMock(return_value=1)
|
||||
mock_cache.connect = AsyncMock(return_value=None)
|
||||
mock_cache._redis = MagicMock()
|
||||
mock_cache._redis.ping = AsyncMock(return_value=True)
|
||||
|
||||
with patch("app.cache.cache", mock_cache), \
|
||||
patch("app.routers.equipment.cache", mock_cache), \
|
||||
patch("app.routers.admin.cache", mock_cache), \
|
||||
patch("app.routers.rental_requests.cache", mock_cache), \
|
||||
patch("app.routers.contact.cache", mock_cache), \
|
||||
patch("app.services.sync_service.cache", mock_cache):
|
||||
from app.main import app
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def seeded_admin(test_engine):
|
||||
"""Seed an admin user into the test database and return credentials."""
|
||||
session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with session_maker() as session:
|
||||
admin = AdminUser(
|
||||
username="admin",
|
||||
password_hash=get_password_hash("testpassword"),
|
||||
)
|
||||
session.add(admin)
|
||||
await session.commit()
|
||||
return {"username": "admin", "password": "testpassword"}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def seeded_equipment(test_engine):
|
||||
"""Seed sample equipment into the test database."""
|
||||
session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with session_maker() as session:
|
||||
items = [
|
||||
EquipmentCache(rentman_id="101", name="L-Acoustics K2", number="K2-001", category="Lautsprecher", brand="L-Acoustics", available=True),
|
||||
EquipmentCache(rentman_id="102", name="L-Acoustics KS28", number="KS28-001", category="Subwoofer", brand="L-Acoustics", available=True),
|
||||
EquipmentCache(rentman_id="103", name="d&b T10", number="T10-001", category="Lautsprecher", brand="d&b audiotechnik", available=True),
|
||||
]
|
||||
for item in items:
|
||||
session.add(item)
|
||||
await session.commit()
|
||||
return items
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Tests for admin authentication (T04)."""
|
||||
import pytest
|
||||
from app.auth import create_access_token, verify_token, get_password_hash, verify_password
|
||||
|
||||
|
||||
def test_password_hash_and_verify():
|
||||
plain = "mysecret"
|
||||
hashed = get_password_hash(plain)
|
||||
assert hashed != plain
|
||||
assert verify_password(plain, hashed) is True
|
||||
assert verify_password("wrong", hashed) is False
|
||||
|
||||
|
||||
def test_create_and_verify_token():
|
||||
token = create_access_token({"sub": "admin"})
|
||||
assert token is not None
|
||||
payload = verify_token(token)
|
||||
assert payload is not None
|
||||
assert payload["sub"] == "admin"
|
||||
|
||||
|
||||
def test_verify_invalid_token():
|
||||
payload = verify_token("invalid.token.here")
|
||||
assert payload is None
|
||||
|
||||
|
||||
def test_verify_expired_token():
|
||||
from datetime import timedelta
|
||||
token = create_access_token({"sub": "admin"}, expires_delta=timedelta(seconds=-1))
|
||||
payload = verify_token(token)
|
||||
assert payload is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(client, seeded_admin):
|
||||
"""POST /api/admin/login with valid credentials returns 200 with token."""
|
||||
resp = await client.post("/api/admin/login", json={
|
||||
"username": "admin",
|
||||
"password": "testpassword",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
assert "hms_admin_token" in resp.cookies
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_failure_wrong_password(client, seeded_admin):
|
||||
"""POST /api/admin/login with wrong password returns 401."""
|
||||
resp = await client.post("/api/admin/login", json={
|
||||
"username": "admin",
|
||||
"password": "wrongpassword",
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_failure_unknown_user(client, seeded_admin):
|
||||
"""POST /api/admin/login with unknown user returns 401."""
|
||||
resp = await client.post("/api/admin/login", json={
|
||||
"username": "unknown",
|
||||
"password": "testpassword",
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me_without_token(client):
|
||||
"""GET /api/admin/me without token returns 401."""
|
||||
resp = await client.get("/api/admin/me")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me_with_valid_token(client, seeded_admin):
|
||||
"""GET /api/admin/me with valid token returns 200 with username."""
|
||||
# Login first
|
||||
login_resp = await client.post("/api/admin/login", json={
|
||||
"username": "admin",
|
||||
"password": "testpassword",
|
||||
})
|
||||
token = login_resp.json()["access_token"]
|
||||
|
||||
resp = await client.get("/api/admin/me", cookies={"hms_admin_token": token})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["username"] == "admin"
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for admin sync endpoints (T04)."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from app.services.sync_service import SyncService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_without_token(client):
|
||||
"""POST /api/admin/sync without JWT returns 401."""
|
||||
resp = await client.post("/api/admin/sync")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_status_without_token(client):
|
||||
"""GET /api/admin/sync-status without JWT returns 401."""
|
||||
resp = await client.get("/api/admin/sync-status")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_log_without_token(client):
|
||||
"""GET /api/admin/sync-log without JWT returns 401."""
|
||||
resp = await client.get("/api/admin/sync-log")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_with_valid_token(client, seeded_admin, test_db):
|
||||
"""POST /api/admin/sync with valid JWT triggers equipment sync."""
|
||||
# Login
|
||||
login_resp = await client.post("/api/admin/login", json={
|
||||
"username": "admin",
|
||||
"password": "testpassword",
|
||||
})
|
||||
token = login_resp.json()["access_token"]
|
||||
|
||||
# Mock sync service
|
||||
with patch.object(SyncService, "run_sync", new_callable=AsyncMock) as mock_sync:
|
||||
mock_sync.return_value = {"sync_id": 42, "items_processed": 10, "items_failed": 0, "status": "completed"}
|
||||
resp = await client.post("/api/admin/sync", cookies={"hms_admin_token": token})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["sync_id"] == 42
|
||||
assert data["status"] == "completed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_status_with_valid_token(client, seeded_admin):
|
||||
"""GET /api/admin/sync-status with valid JWT returns status."""
|
||||
login_resp = await client.post("/api/admin/login", json={
|
||||
"username": "admin",
|
||||
"password": "testpassword",
|
||||
})
|
||||
token = login_resp.json()["access_token"]
|
||||
|
||||
with patch.object(SyncService, "get_last_sync", new_callable=AsyncMock) as mock_status:
|
||||
mock_status.return_value = {"last_sync": None, "items_processed": 0, "status": "never"}
|
||||
resp = await client.get("/api/admin/sync-status", cookies={"hms_admin_token": token})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "never"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_log_with_valid_token(client, seeded_admin):
|
||||
"""GET /api/admin/sync-log with valid JWT returns paginated log."""
|
||||
login_resp = await client.post("/api/admin/login", json={
|
||||
"username": "admin",
|
||||
"password": "testpassword",
|
||||
})
|
||||
token = login_resp.json()["access_token"]
|
||||
|
||||
mock_log = MagicMock()
|
||||
mock_log.id = 1
|
||||
mock_log.sync_type = "equipment"
|
||||
mock_log.status = "completed"
|
||||
mock_log.items_processed = 100
|
||||
mock_log.items_failed = 0
|
||||
mock_log.error_message = None
|
||||
from datetime import datetime
|
||||
mock_log.started_at = datetime(2026, 7, 9, 12, 0, 0)
|
||||
mock_log.completed_at = datetime(2026, 7, 9, 12, 5, 0)
|
||||
|
||||
with patch.object(SyncService, "get_sync_log_paginated", new_callable=AsyncMock) as mock_log_fn:
|
||||
mock_log_fn.return_value = {
|
||||
"items": [mock_log],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total_pages": 1,
|
||||
}
|
||||
resp = await client.get("/api/admin/sync-log", cookies={"hms_admin_token": token})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["status"] == "completed"
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests for contact router (T05)."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.contact import Contact
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contact_valid_returns_200(client):
|
||||
"""POST /api/contact with valid data returns 200 and saves to contacts table."""
|
||||
with patch(
|
||||
"app.services.email_service.EmailService.send_contact_email",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
resp = await client.post("/api/contact", json={
|
||||
"name": "Max Mustermann",
|
||||
"email": "max@example.com",
|
||||
"phone": "+49 123 456789",
|
||||
"message": "Ich brauche eine PA-Anlage fuer ein Festival.",
|
||||
"privacy_consent": True,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contact_invalid_email_returns_422(client):
|
||||
"""POST /api/contact with invalid email returns 422."""
|
||||
resp = await client.post("/api/contact", json={
|
||||
"name": "Max Mustermann",
|
||||
"email": "not-an-email",
|
||||
"phone": "+49 123 456789",
|
||||
"message": "Test message",
|
||||
"privacy_consent": True,
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contact_privacy_consent_false_returns_422(client):
|
||||
"""POST /api/contact with privacy_consent=false returns 422."""
|
||||
resp = await client.post("/api/contact", json={
|
||||
"name": "Max Mustermann",
|
||||
"email": "max@example.com",
|
||||
"phone": "+49 123 456789",
|
||||
"message": "Test message",
|
||||
"privacy_consent": False,
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contact_triggers_email(client):
|
||||
"""POST /api/contact triggers send_contact_email on EmailService."""
|
||||
with patch(
|
||||
"app.services.email_service.EmailService.send_contact_email",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
) as mock_send:
|
||||
resp = await client.post("/api/contact", json={
|
||||
"name": "Erika Musterfrau",
|
||||
"email": "erika@example.com",
|
||||
"phone": "+49 987 654321",
|
||||
"message": "Ich benoetige Beleuchtung fuer eine Gala.",
|
||||
"privacy_consent": True,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
mock_send.assert_awaited_once()
|
||||
call_args = mock_send.call_args[0][0]
|
||||
assert call_args["name"] == "Erika Musterfrau"
|
||||
assert call_args["email"] == "erika@example.com"
|
||||
assert call_args["message"] == "Ich benoetige Beleuchtung fuer eine Gala."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contact_saved_to_db(client, test_db):
|
||||
"""POST /api/contact saves a record to the contacts table."""
|
||||
with patch(
|
||||
"app.services.email_service.EmailService.send_contact_email",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
resp = await client.post("/api/contact", json={
|
||||
"name": "DB Testuser",
|
||||
"email": "db@example.com",
|
||||
"phone": "+49 111 222333",
|
||||
"message": "Datenbank-Verifikation",
|
||||
"privacy_consent": True,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
result = await test_db.execute(select(Contact).where(Contact.email == "db@example.com"))
|
||||
contact = result.scalar_one_or_none()
|
||||
assert contact is not None
|
||||
assert contact.name == "DB Testuser"
|
||||
assert contact.message == "Datenbank-Verifikation"
|
||||
assert contact.privacy_consent is True
|
||||
assert contact.email_sent is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contact_email_failure_does_not_break_response(client):
|
||||
"""POST /api/contact still returns 200 when email sending fails."""
|
||||
with patch(
|
||||
"app.services.email_service.EmailService.send_contact_email",
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
):
|
||||
resp = await client.post("/api/contact", json={
|
||||
"name": "Fail Test",
|
||||
"email": "fail@example.com",
|
||||
"phone": "+49 000 000000",
|
||||
"message": "Email soll fehlschlagen",
|
||||
"privacy_consent": True,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Tests for email service (T05)."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from app.services.email_service import EmailService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_contact_email_success():
|
||||
"""send_contact_email sends correct email via aiosmtplib."""
|
||||
service = EmailService()
|
||||
contact_data = {
|
||||
"name": "Test User",
|
||||
"email": "test@example.com",
|
||||
"phone": "+49 123 456",
|
||||
"message": "Hallo Welt",
|
||||
}
|
||||
with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock) as mock_send:
|
||||
result = await service.send_contact_email(contact_data)
|
||||
assert result is True
|
||||
mock_send.assert_awaited_once()
|
||||
sent_msg = mock_send.call_args[0][0]
|
||||
assert isinstance(sent_msg, MIMEMultipart)
|
||||
assert "Hallo Welt" in str(sent_msg)
|
||||
assert "Test User" in str(sent_msg)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_rental_confirmation_success():
|
||||
"""send_rental_confirmation sends email with reference_number and items."""
|
||||
service = EmailService()
|
||||
items = [
|
||||
{"equipment_name": "L-Acoustics K2", "quantity": 2},
|
||||
{"equipment_name": "d&b T10", "quantity": 4},
|
||||
]
|
||||
with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock) as mock_send:
|
||||
result = await service.send_rental_confirmation(
|
||||
to_addr="customer@example.com",
|
||||
reference_number="HMS-2026-00042",
|
||||
event_name="Rock Festival",
|
||||
items=items,
|
||||
)
|
||||
assert result is True
|
||||
mock_send.assert_awaited_once()
|
||||
sent_msg = mock_send.call_args[0][0]
|
||||
msg_str = str(sent_msg)
|
||||
assert "HMS-2026-00042" in msg_str
|
||||
assert "Rock Festival" in msg_str
|
||||
assert "L-Acoustics K2" in msg_str
|
||||
assert "d&b T10" in msg_str
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_email_smtp_failure_returns_false():
|
||||
"""SMTP failure returns False, no crash, graceful handling."""
|
||||
service = EmailService()
|
||||
with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock, side_effect=Exception("SMTP refused")):
|
||||
result = await service.send_email(
|
||||
to_addr="fail@example.com",
|
||||
subject="Test Subject",
|
||||
html_body="<p>Test</p>",
|
||||
text_body="Test",
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_contact_email_smtp_failure_returns_false():
|
||||
"""send_contact_email returns False on SMTP failure without crashing."""
|
||||
service = EmailService()
|
||||
with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock, side_effect=Exception("Connection refused")):
|
||||
result = await service.send_contact_email({
|
||||
"name": "Fail User",
|
||||
"email": "fail@example.com",
|
||||
"phone": "+49 000",
|
||||
"message": "Soll fehlschlagen",
|
||||
})
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_rental_confirmation_smtp_failure_returns_false():
|
||||
"""send_rental_confirmation returns False on SMTP failure without crashing."""
|
||||
service = EmailService()
|
||||
with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock, side_effect=Exception("Timeout")):
|
||||
result = await service.send_rental_confirmation(
|
||||
to_addr="fail@example.com",
|
||||
reference_number="HMS-2026-99999",
|
||||
event_name="Fail Event",
|
||||
items=[{"equipment_name": "Speaker", "quantity": 1}],
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_email_failure_saves_to_queue():
|
||||
"""send_email saves failed email to email_queue when db is provided."""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from app.database import Base
|
||||
from app.models.email_queue import EmailQueue
|
||||
from sqlalchemy import select
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
service = EmailService()
|
||||
with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock, side_effect=Exception("SMTP down")):
|
||||
async with session_maker() as db:
|
||||
result = await service.send_email(
|
||||
to_addr="queue@example.com",
|
||||
subject="Queued Email",
|
||||
html_body="<p>Body</p>",
|
||||
text_body="Body",
|
||||
reply_to="reply@example.com",
|
||||
db=db,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
async with session_maker() as db:
|
||||
queued = await db.execute(select(EmailQueue).where(EmailQueue.to_addr == "queue@example.com"))
|
||||
item = queued.scalar_one_or_none()
|
||||
assert item is not None
|
||||
assert item.subject == "Queued Email"
|
||||
assert item.status == "pending"
|
||||
assert item.attempts == 0
|
||||
assert item.reply_to == "reply@example.com"
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_failed_emails_resends_pending():
|
||||
"""retry_failed_emails retries pending queue items and marks sent on success."""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from app.database import Base
|
||||
from app.models.email_queue import EmailQueue
|
||||
from sqlalchemy import select
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with session_maker() as db:
|
||||
db.add(EmailQueue(
|
||||
to_addr="retry@example.com",
|
||||
subject="Retry Test",
|
||||
html_body="<p>Retry</p>",
|
||||
text_body="Retry",
|
||||
status="pending",
|
||||
attempts=0,
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock):
|
||||
async with session_maker() as db:
|
||||
sent_count = await EmailService.retry_failed_emails(db)
|
||||
assert sent_count == 1
|
||||
|
||||
async with session_maker() as db:
|
||||
result = await db.execute(select(EmailQueue).where(EmailQueue.to_addr == "retry@example.com"))
|
||||
item = result.scalar_one_or_none()
|
||||
assert item.status == "sent"
|
||||
assert item.attempts == 1
|
||||
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Tests for equipment router (T03)."""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_equipment(client, seeded_equipment):
|
||||
resp = await client.get("/api/equipment?page=1&page_size=10")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert "page" in data
|
||||
assert "page_size" in data
|
||||
assert "total_pages" in data
|
||||
assert data["total"] == 3
|
||||
assert len(data["items"]) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_equipment(client, seeded_equipment):
|
||||
resp = await client.get("/api/equipment?search=K2")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert "K2" in data["items"][0]["name"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_category(client, seeded_equipment):
|
||||
resp = await client.get("/api/equipment?category=Lautsprecher")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 2
|
||||
for item in data["items"]:
|
||||
assert item["category"] == "Lautsprecher"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sort_name_asc(client, seeded_equipment):
|
||||
resp = await client.get("/api/equipment?sort=name_asc")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
names = [item["name"] for item in data["items"]]
|
||||
assert names == sorted(names)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sort_name_desc(client, seeded_equipment):
|
||||
resp = await client.get("/api/equipment?sort=name_desc")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
names = [item["name"] for item in data["items"]]
|
||||
assert names == sorted(names, reverse=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_categories(client, seeded_equipment):
|
||||
resp = await client.get("/api/equipment/categories")
|
||||
assert resp.status_code == 200
|
||||
cats = resp.json()
|
||||
assert "Lautsprecher" in cats
|
||||
assert "Subwoofer" in cats
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_equipment_detail(client, seeded_equipment):
|
||||
resp = await client.get(f"/api/equipment/{seeded_equipment[0].id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "L-Acoustics K2"
|
||||
assert data["brand"] == "L-Acoustics"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_equipment_not_found(client, seeded_equipment):
|
||||
resp = await client.get("/api/equipment/999999")
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Tests for health endpoint (T03)."""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_endpoint(client):
|
||||
resp = await client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert "db" in data
|
||||
assert "redis" in data
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests for rate limiting on contact and rental endpoints (T05)."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.models import EquipmentCache
|
||||
|
||||
|
||||
VALID_CONTACT = {
|
||||
"name": "Rate Limit",
|
||||
"email": "rate@example.com",
|
||||
"phone": "+49 123",
|
||||
"message": "Rate limit test",
|
||||
"privacy_consent": True,
|
||||
}
|
||||
|
||||
VALID_RENTAL = {
|
||||
"event_name": "Rate Limit Fest",
|
||||
"date_start": "2026-09-01",
|
||||
"date_end": "2026-09-02",
|
||||
"location": "Hamburg",
|
||||
"contact_name": "RL User",
|
||||
"contact_email": "rl@example.com",
|
||||
"items": [{"equipment_id": 1, "quantity": 1}],
|
||||
}
|
||||
|
||||
|
||||
def _make_rate_limit_client(test_engine, rate_counts: list[int]):
|
||||
"""Create a client whose cache.incr_rate returns sequential values from rate_counts."""
|
||||
session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
call_index = {"i": 0}
|
||||
|
||||
async def override_get_db():
|
||||
async with session_maker() as session:
|
||||
yield session
|
||||
|
||||
async def mock_incr_rate(key: str, window: int = 60) -> int:
|
||||
idx = call_index["i"]
|
||||
if idx < len(rate_counts):
|
||||
val = rate_counts[idx]
|
||||
else:
|
||||
val = rate_counts[-1] + 1
|
||||
call_index["i"] += 1
|
||||
return val
|
||||
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.get = AsyncMock(return_value=None)
|
||||
mock_cache.set = AsyncMock(return_value=None)
|
||||
mock_cache.delete_pattern = AsyncMock(return_value=0)
|
||||
mock_cache.incr_rate = mock_incr_rate
|
||||
mock_cache.connect = AsyncMock(return_value=None)
|
||||
mock_cache._redis = MagicMock()
|
||||
mock_cache._redis.ping = AsyncMock(return_value=True)
|
||||
|
||||
async def _yield_client():
|
||||
with patch("app.cache.cache", mock_cache), \
|
||||
patch("app.routers.equipment.cache", mock_cache), \
|
||||
patch("app.routers.admin.cache", mock_cache), \
|
||||
patch("app.routers.rental_requests.cache", mock_cache), \
|
||||
patch("app.routers.contact.cache", mock_cache), \
|
||||
patch("app.services.sync_service.cache", mock_cache):
|
||||
from app.main import app
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
return _yield_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contact_rate_limit_allows_5_blocks_6th(test_engine):
|
||||
"""5 requests OK, 6th request returns 429 for contact endpoint."""
|
||||
rate_counts = [1, 2, 3, 4, 5, 6]
|
||||
client_gen = _make_rate_limit_client(test_engine, rate_counts)
|
||||
async for client in client_gen():
|
||||
with patch(
|
||||
"app.services.email_service.EmailService.send_contact_email",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
for i in range(5):
|
||||
resp = await client.post("/api/contact", json=VALID_CONTACT)
|
||||
assert resp.status_code == 200, f"Request {i+1} should succeed"
|
||||
resp = await client.post("/api/contact", json=VALID_CONTACT)
|
||||
assert resp.status_code == 429
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rental_rate_limit_allows_5_blocks_6th(test_engine, seeded_equipment):
|
||||
"""5 requests OK, 6th request returns 429 for rental-requests endpoint."""
|
||||
rate_counts = [1, 2, 3, 4, 5, 6]
|
||||
client_gen = _make_rate_limit_client(test_engine, rate_counts)
|
||||
async for client in client_gen():
|
||||
with patch(
|
||||
"app.services.rentman_service.RentmanService.create_project_request",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"id": "rl-test"},
|
||||
), patch(
|
||||
"app.services.rentman_service.RentmanService.add_equipment_to_request",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"id": "eq-rl"},
|
||||
), patch(
|
||||
"app.services.email_service.EmailService.send_rental_confirmation",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
for i in range(5):
|
||||
resp = await client.post("/api/rental-requests", json=VALID_RENTAL)
|
||||
assert resp.status_code == 201, f"Request {i+1} should succeed"
|
||||
resp = await client.post("/api/rental-requests", json=VALID_RENTAL)
|
||||
assert resp.status_code == 429
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contact_rate_window_reset(test_engine):
|
||||
"""Rate window resets after TTL: first 5 OK, then 429, then after reset 5 more OK."""
|
||||
rate_counts = [1, 2, 3, 4, 5, 6, 1, 2]
|
||||
client_gen = _make_rate_limit_client(test_engine, rate_counts)
|
||||
async for client in client_gen():
|
||||
with patch(
|
||||
"app.services.email_service.EmailService.send_contact_email",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
for i in range(5):
|
||||
resp = await client.post("/api/contact", json=VALID_CONTACT)
|
||||
assert resp.status_code == 200
|
||||
resp = await client.post("/api/contact", json=VALID_CONTACT)
|
||||
assert resp.status_code == 429
|
||||
# After window reset, counter starts at 1 again
|
||||
resp = await client.post("/api/contact", json=VALID_CONTACT)
|
||||
assert resp.status_code == 200
|
||||
resp = await client.post("/api/contact", json=VALID_CONTACT)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Tests for rental request router (T05)."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.rental_request import RentalRequest, RentalRequestItem
|
||||
|
||||
|
||||
VALID_RENTAL_PAYLOAD = {
|
||||
"event_name": "Sommerfestival 2026",
|
||||
"date_start": "2026-08-15",
|
||||
"date_end": "2026-08-17",
|
||||
"location": "Berlin",
|
||||
"person_count": 500,
|
||||
"contact_name": "Max Veranstalter",
|
||||
"contact_company": "Event GmbH",
|
||||
"contact_email": "max@event.de",
|
||||
"contact_phone": "+49 30 1234567",
|
||||
"contact_street": "Hauptstr. 42",
|
||||
"contact_postalcode": "10115",
|
||||
"contact_city": "Berlin",
|
||||
"message": "Bitte um Angebot",
|
||||
"items": [{"equipment_id": 1, "quantity": 2}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rental_valid_returns_201(client, seeded_equipment):
|
||||
"""POST /api/rental-requests with valid data returns 201 with reference number."""
|
||||
with patch(
|
||||
"app.services.rentman_service.RentmanService.create_project_request",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"id": "rentman-123"},
|
||||
), patch(
|
||||
"app.services.rentman_service.RentmanService.add_equipment_to_request",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"id": "eq-1"},
|
||||
), patch(
|
||||
"app.services.email_service.EmailService.send_rental_confirmation",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
resp = await client.post("/api/rental-requests", json=VALID_RENTAL_PAYLOAD)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert "reference_number" in data
|
||||
ref = data["reference_number"]
|
||||
assert ref.startswith("HMS-")
|
||||
parts = ref.split("-")
|
||||
assert len(parts) == 3
|
||||
assert parts[1].isdigit()
|
||||
assert len(parts[2]) == 5
|
||||
assert parts[2].isdigit()
|
||||
assert data["status"] == "pending"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rental_date_end_before_start_returns_422(client, seeded_equipment):
|
||||
"""POST /api/rental-requests with date_end < date_start returns 422."""
|
||||
payload = {**VALID_RENTAL_PAYLOAD, "date_start": "2026-08-20", "date_end": "2026-08-15"}
|
||||
resp = await client.post("/api/rental-requests", json=payload)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rental_empty_items_returns_422(client, seeded_equipment):
|
||||
"""POST /api/rental-requests with empty items array returns 422."""
|
||||
payload = {**VALID_RENTAL_PAYLOAD, "items": []}
|
||||
resp = await client.post("/api/rental-requests", json=payload)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rental_saved_to_db(client, seeded_equipment, test_db):
|
||||
"""POST /api/rental-requests saves to rental_requests and rental_request_items tables."""
|
||||
with patch(
|
||||
"app.services.rentman_service.RentmanService.create_project_request",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"id": "rentman-456"},
|
||||
), patch(
|
||||
"app.services.rentman_service.RentmanService.add_equipment_to_request",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"id": "eq-2"},
|
||||
), patch(
|
||||
"app.services.email_service.EmailService.send_rental_confirmation",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
resp = await client.post("/api/rental-requests", json=VALID_RENTAL_PAYLOAD)
|
||||
assert resp.status_code == 201
|
||||
ref_number = resp.json()["reference_number"]
|
||||
|
||||
result = await test_db.execute(
|
||||
select(RentalRequest).where(RentalRequest.reference_number == ref_number)
|
||||
)
|
||||
rental = result.scalar_one_or_none()
|
||||
assert rental is not None
|
||||
assert rental.event_name == "Sommerfestival 2026"
|
||||
assert rental.contact_name == "Max Veranstalter"
|
||||
assert rental.status == "pending"
|
||||
assert rental.rentman_request_id == "rentman-456"
|
||||
assert rental.rentman_sync_status == "success"
|
||||
|
||||
items_result = await test_db.execute(
|
||||
select(RentalRequestItem).where(RentalRequestItem.rental_request_id == rental.id)
|
||||
)
|
||||
items = items_result.scalars().all()
|
||||
assert len(items) == 1
|
||||
assert items[0].equipment_id == 1
|
||||
assert items[0].quantity == 2
|
||||
assert items[0].equipment_name == "L-Acoustics K2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rental_triggers_rentman(client, seeded_equipment):
|
||||
"""POST /api/rental-requests triggers Rentman create_project_request."""
|
||||
with patch(
|
||||
"app.services.rentman_service.RentmanService.create_project_request",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"id": "rentman-789"},
|
||||
) as mock_create, patch(
|
||||
"app.services.rentman_service.RentmanService.add_equipment_to_request",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"id": "eq-3"},
|
||||
) as mock_add, patch(
|
||||
"app.services.email_service.EmailService.send_rental_confirmation",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
resp = await client.post("/api/rental-requests", json=VALID_RENTAL_PAYLOAD)
|
||||
assert resp.status_code == 201
|
||||
mock_create.assert_awaited_once()
|
||||
mock_add.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rental_triggers_email(client, seeded_equipment):
|
||||
"""POST /api/rental-requests triggers confirmation email."""
|
||||
with patch(
|
||||
"app.services.rentman_service.RentmanService.create_project_request",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"id": "rentman-email-test"},
|
||||
), patch(
|
||||
"app.services.rentman_service.RentmanService.add_equipment_to_request",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"id": "eq-4"},
|
||||
), patch(
|
||||
"app.services.email_service.EmailService.send_rental_confirmation",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
) as mock_email:
|
||||
resp = await client.post("/api/rental-requests", json=VALID_RENTAL_PAYLOAD)
|
||||
assert resp.status_code == 201
|
||||
mock_email.assert_awaited_once()
|
||||
call_kwargs = mock_email.call_args.kwargs
|
||||
assert call_kwargs["to_addr"] == "max@event.de"
|
||||
assert call_kwargs["event_name"] == "Sommerfestival 2026"
|
||||
assert call_kwargs["reference_number"].startswith("HMS-")
|
||||
assert len(call_kwargs["items"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rental_rentman_failure_still_returns_201(client, seeded_equipment):
|
||||
"""POST /api/rental-requests still returns 201 when Rentman API fails."""
|
||||
with patch(
|
||||
"app.services.rentman_service.RentmanService.create_project_request",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("Rentman API error"),
|
||||
), patch(
|
||||
"app.services.email_service.EmailService.send_rental_confirmation",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
resp = await client.post("/api/rental-requests", json=VALID_RENTAL_PAYLOAD)
|
||||
assert resp.status_code == 201
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Tests for Rentman equipment import pipeline (T04)."""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from sqlalchemy import select
|
||||
from app.models.equipment import EquipmentCache
|
||||
from app.models.sync_log import SyncLog
|
||||
from app.services.sync_service import SyncService
|
||||
from app.services.rentman_service import RentmanService
|
||||
|
||||
|
||||
def make_raw_equipment(rid: str, name: str, category: str = "Lautsprecher") -> dict:
|
||||
return {
|
||||
"id": rid,
|
||||
"name": name,
|
||||
"number": f"{name[:3].upper()}-001",
|
||||
"code": f"{name[:3].upper()}-001",
|
||||
"equipment_group": {"name": category},
|
||||
"description": f"Description for {name}",
|
||||
"specifications": {"weight": 50, "power": 750},
|
||||
"images": [{"url": f"https://example.com/{rid}.jpg"}],
|
||||
"rental_price": 150.00,
|
||||
"brand": "L-Acoustics",
|
||||
"available": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transform_equipment():
|
||||
raw = make_raw_equipment("42", "K2 Line Array", "Lautsprecher")
|
||||
result = RentmanService.transform_equipment(raw)
|
||||
assert result["rentman_id"] == "42"
|
||||
assert result["name"] == "K2 Line Array"
|
||||
assert result["category"] == "Lautsprecher"
|
||||
assert result["images"] == ["https://example.com/42.jpg"]
|
||||
assert result["brand"] == "L-Acoustics"
|
||||
assert result["available"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paginated_import(test_db):
|
||||
"""Import service iterates all pages (3 pages with 250 items)."""
|
||||
page1 = {"data": [make_raw_equipment(str(i), f"Item {i}") for i in range(100)], "itemCount": 250}
|
||||
page2 = {"data": [make_raw_equipment(str(i), f"Item {i}") for i in range(100, 200)], "itemCount": 250}
|
||||
page3 = {"data": [make_raw_equipment(str(i), f"Item {i}") for i in range(200, 250)], "itemCount": 250}
|
||||
page4 = {"data": [], "itemCount": 250}
|
||||
|
||||
mock_rentman = MagicMock()
|
||||
mock_rentman.get_all_equipment = AsyncMock(return_value=[
|
||||
*[make_raw_equipment(str(i), f"Item {i}") for i in range(250)]
|
||||
])
|
||||
|
||||
with patch("app.services.sync_service.cache") as mock_cache:
|
||||
mock_cache.delete_pattern = AsyncMock(return_value=0)
|
||||
sync_service = SyncService(test_db, rentman=mock_rentman)
|
||||
result = await sync_service.run_sync()
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["items_processed"] == 250
|
||||
assert result["items_failed"] == 0
|
||||
|
||||
# Verify equipment was upserted
|
||||
db_result = await test_db.execute(select(EquipmentCache))
|
||||
items = db_result.scalars().all()
|
||||
assert len(items) == 250
|
||||
|
||||
# Verify sync_log entry
|
||||
log_result = await test_db.execute(select(SyncLog))
|
||||
logs = log_result.scalars().all()
|
||||
assert len(logs) == 1
|
||||
assert logs[0].status == "completed"
|
||||
assert logs[0].items_processed == 250
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_upsert_existing(test_db):
|
||||
"""Upsert should update existing equipment, not duplicate."""
|
||||
existing = EquipmentCache(rentman_id="100", name="Old Name", category="Old")
|
||||
test_db.add(existing)
|
||||
await test_db.commit()
|
||||
|
||||
mock_rentman = MagicMock()
|
||||
mock_rentman.get_all_equipment = AsyncMock(return_value=[
|
||||
make_raw_equipment("100", "New Name", "Lautsprecher")
|
||||
])
|
||||
|
||||
with patch("app.services.sync_service.cache") as mock_cache:
|
||||
mock_cache.delete_pattern = AsyncMock(return_value=0)
|
||||
sync_service = SyncService(test_db, rentman=mock_rentman)
|
||||
result = await sync_service.run_sync()
|
||||
|
||||
assert result["items_processed"] == 1
|
||||
db_result = await test_db.execute(select(EquipmentCache))
|
||||
items = db_result.scalars().all()
|
||||
assert len(items) == 1
|
||||
assert items[0].name == "New Name"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_failure_logs_error(test_db):
|
||||
"""Sync failure should be logged with error message."""
|
||||
mock_rentman = MagicMock()
|
||||
mock_rentman.get_all_equipment = AsyncMock(side_effect=Exception("API unreachable"))
|
||||
|
||||
with patch("app.services.sync_service.cache") as mock_cache:
|
||||
mock_cache.delete_pattern = AsyncMock(return_value=0)
|
||||
sync_service = SyncService(test_db, rentman=mock_rentman)
|
||||
result = await sync_service.run_sync()
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["items_processed"] == 0
|
||||
|
||||
log_result = await test_db.execute(select(SyncLog))
|
||||
log = log_result.scalar_one()
|
||||
assert log.status == "failed"
|
||||
assert "API unreachable" in (log.error_message or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_equipment_paginates():
|
||||
"""RentmanService.get_all_equipment iterates until data is empty."""
|
||||
page1 = {"data": [{"id": str(i), "name": f"Item {i}"} for i in range(100)]}
|
||||
page2 = {"data": [{"id": str(i), "name": f"Item {i}"} for i in range(100, 150)]}
|
||||
page3 = {"data": []}
|
||||
|
||||
svc = RentmanService(token="test-token")
|
||||
svc.get_equipment_page = AsyncMock(side_effect=[page1, page2, page3])
|
||||
result = await svc.get_all_equipment(limit=100)
|
||||
|
||||
assert len(result) == 150
|
||||
assert svc.get_equipment_page.call_count == 3
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests for Rentman request submission pipeline (T04)."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from app.services.rentman_service import RentmanService
|
||||
|
||||
|
||||
def test_build_project_request_payload():
|
||||
data = {
|
||||
"event_name": "Sommerfest 2026",
|
||||
"date_start": "2026-08-15",
|
||||
"date_end": "2026-08-16",
|
||||
"contact_name": "Max Mustermann",
|
||||
"contact_company": "Firma GmbH",
|
||||
"contact_email": "max@example.com",
|
||||
"contact_phone": "+49 170 1234567",
|
||||
"contact_street": "Hauptstr. 42a",
|
||||
"contact_postalcode": "80000",
|
||||
"contact_city": "Muenchen",
|
||||
"location": "Muenchen",
|
||||
"message": "Brauchen PA",
|
||||
}
|
||||
payload = RentmanService.build_project_request_payload(data)
|
||||
assert payload["name"] == "Sommerfest 2026"
|
||||
assert payload["planperiod_start"] == "2026-08-15T08:00:00+02:00"
|
||||
assert payload["planperiod_end"] == "2026-08-16T02:00:00+02:00"
|
||||
assert payload["usageperiod_start"] == "2026-08-15T18:00:00+02:00"
|
||||
assert payload["usageperiod_end"] == "2026-08-16T23:59:00+02:00"
|
||||
assert payload["contact_name"] == "Firma GmbH"
|
||||
assert payload["contact_person_first_name"] == "Max"
|
||||
assert payload["contact_person_lastname"] == "Mustermann"
|
||||
assert payload["contact_person_email"] == "max@example.com"
|
||||
assert payload["location_mailing_street"] == "Hauptstr. 42a"
|
||||
assert payload["location_mailing_number"] == "42a"
|
||||
assert payload["location_mailing_postalcode"] == "80000"
|
||||
assert payload["location_mailing_city"] == "Muenchen"
|
||||
assert payload["remark"] == "Brauchen PA"
|
||||
|
||||
|
||||
def test_build_project_request_payload_no_company():
|
||||
"""When no company, contact_name should use contact_name."""
|
||||
data = {
|
||||
"event_name": "Test",
|
||||
"date_start": "2026-08-15",
|
||||
"date_end": "2026-08-16",
|
||||
"contact_name": "Anna Schmidt",
|
||||
"contact_company": None,
|
||||
"contact_email": "anna@example.com",
|
||||
"contact_street": "Testweg 5",
|
||||
"contact_postalcode": "10000",
|
||||
"contact_city": "Berlin",
|
||||
"location": "Berlin",
|
||||
}
|
||||
payload = RentmanService.build_project_request_payload(data)
|
||||
assert payload["contact_name"] == "Anna Schmidt"
|
||||
assert payload["contact_person_first_name"] == "Anna"
|
||||
assert payload["contact_person_lastname"] == "Schmidt"
|
||||
assert payload["location_mailing_number"] == "5"
|
||||
|
||||
|
||||
def test_build_equipment_payload():
|
||||
item = {
|
||||
"equipment_name": "L-Acoustics K2",
|
||||
"rentman_equipment_id": "101",
|
||||
"quantity": 4,
|
||||
"unit_price": 150.00,
|
||||
}
|
||||
payload = RentmanService.build_equipment_payload(item)
|
||||
assert payload["name"] == "L-Acoustics K2"
|
||||
assert payload["quantity"] == 4
|
||||
assert payload["quantity_total"] == 4
|
||||
assert payload["unit_price"] == 150.00
|
||||
assert payload["linked_equipment"] == "/equipment/101"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_projectrequest_success():
|
||||
"""Mock Rentman API: create project request returns ID, equipment added."""
|
||||
svc = RentmanService(token="test-token")
|
||||
svc.create_project_request = AsyncMock(return_value={"id": "9999", "name": "Test Event"})
|
||||
svc.add_equipment_to_request = AsyncMock(return_value={"id": "equip-1"})
|
||||
|
||||
project_payload = {"name": "Test Event", "usageperiod_start": "2026-08-15T18:00:00+02:00"}
|
||||
result = await svc.create_project_request(project_payload)
|
||||
assert result["id"] == "9999"
|
||||
|
||||
equip_payload = {"name": "K2", "quantity": 2}
|
||||
eq_result = await svc.add_equipment_to_request("9999", equip_payload)
|
||||
assert eq_result["id"] == "equip-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_equipment_retry():
|
||||
"""Failed equipment POST should be retried (exponential backoff simulation)."""
|
||||
svc = RentmanService(token="test-token")
|
||||
call_count = 0
|
||||
|
||||
async def mock_add(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
raise Exception("Temporary failure")
|
||||
return {"id": "success"}
|
||||
|
||||
svc.add_equipment_to_request = mock_add
|
||||
|
||||
# Simulate retry logic
|
||||
max_retries = 3
|
||||
result = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
result = await svc.add_equipment_to_request("1", {"name": "test"})
|
||||
break
|
||||
except Exception:
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
import asyncio
|
||||
await asyncio.sleep(0.01 * (2 ** attempt))
|
||||
|
||||
assert result == {"id": "success"}
|
||||
assert call_count == 3
|
||||
@@ -0,0 +1,100 @@
|
||||
services:
|
||||
frontend:
|
||||
build: ./frontend
|
||||
labels:
|
||||
- traefik.http.services.https-0-wvus7va5u0f9dmg27ggca7rl-frontend.loadbalancer.server.port=3000
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- curl -sf http://localhost:3000/ || 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}
|
||||
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN:-}
|
||||
volumes:
|
||||
- /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")
|
||||
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
|
||||
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||
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: null
|
||||
redis_data: null
|
||||
@@ -0,0 +1,107 @@
|
||||
services:
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- NUXT_PUBLIC_API_BASE=/api
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.middlewares.hms-gzip.compress=true"
|
||||
- "traefik.http.middlewares.hms-redirect.redirectscheme.scheme=https"
|
||||
- "traefik.http.routers.hms-http.entryPoints=http"
|
||||
- "traefik.http.routers.hms-http.middlewares=hms-redirect"
|
||||
- "traefik.http.routers.hms-http.rule=Host(`hms.media-on.de`) && PathPrefix(`/`)"
|
||||
- "traefik.http.routers.hms-https.entryPoints=https"
|
||||
- "traefik.http.routers.hms-https.middlewares=hms-gzip"
|
||||
- "traefik.http.routers.hms-https.rule=Host(`hms.media-on.de`) && PathPrefix(`/`)"
|
||||
- "traefik.http.routers.hms-https.tls=true"
|
||||
- "traefik.http.routers.hms-https.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.hms.loadbalancer.server.port=3000"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- hms-network
|
||||
- coolify
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://hms:hms@postgres:5432/hms}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
|
||||
- RENTMAN_API_TOKEN=${RENTMAN_API_TOKEN}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
- SMTP_FROM=${SMTP_FROM:-info@hms-licht-ton.de}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-https://hms.media-on.de,http://localhost:3000}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
volumes:
|
||||
- ./images:/data/images
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- hms-network
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_USER=hms
|
||||
- POSTGRES_PASSWORD=hms
|
||||
- POSTGRES_DB=hms
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U hms -d hms"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
networks:
|
||||
- hms-network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
networks:
|
||||
- hms-network
|
||||
|
||||
networks:
|
||||
hms-network:
|
||||
driver: bridge
|
||||
coolify:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
@@ -0,0 +1,508 @@
|
||||
# HMS Licht & Ton – Design-Richtlinien für KI-Agenten
|
||||
|
||||
> **VERBINDLICH:** Diese Regeln MÜSSEN bei jeder Erweiterung der Website eingehalten werden.
|
||||
> Abweichungen sind Fehler. Der freigegebene Prototyp ist die Quelle der Wahrheit.
|
||||
> Prototyp: `https://webspace.media-on.de/hms-prototype/index.html?v=8`
|
||||
> Referenz-Dateien: `docs/prototype-app.js`, `docs/prototype-index.html`, `frontend/assets/css/prototype-style.css`
|
||||
|
||||
---
|
||||
|
||||
## 1. Farbsystem (Dark Theme + Orange Akzent)
|
||||
|
||||
### Hauptfarben (Agent Zero Dark Theme Grautöne)
|
||||
| Token | Hex | Verwendung |
|
||||
|-------|-----|------------|
|
||||
| `--bg` | `#131313` | Seiten-Hintergrund |
|
||||
| `--panel` | `#1a1a1a` | Panels, Cards, Header |
|
||||
| `--surface` | `#212121` | Input-Felder, Surface-Level |
|
||||
| `--row` | `#272727` | List-Items, Zwischenebenen |
|
||||
| `--card` | `#2d2d2d` | Card-Hintergrund |
|
||||
| `--border` | `#444444a8` | Borders (mit Alpha) |
|
||||
| `--border-strong` | `#555555` | Strong Borders (Inputs) |
|
||||
| `--secondary` | `#656565` | Sekundärfarbe, Icons, grauer Text |
|
||||
| `--primary` | `#737a81` | Primärfarbe (leicht bläuliches Grau) |
|
||||
| `--text` | `#ffffff` | Haupttext |
|
||||
| `--text-muted` | `#d4d4d4` | Gedimmter Text |
|
||||
|
||||
### Orange Akzent (DEZENT – keine großen Flächen!)
|
||||
| Token | Wert | Verwendung |
|
||||
|-------|------|------------|
|
||||
| `--color-accent` | `#EC6925` | Buttons, Badges, Text-Akzente, Logo-Border |
|
||||
| `--color-accent-hover` | `#d4581a` | Button Hover |
|
||||
| `--color-accent-light` | `rgba(236, 105, 37, 0.08)` | Badge-Hintergrund, Nav-Active |
|
||||
| `--color-accent-border` | `rgba(236, 105, 37, 0.25)` | Card Hover Border |
|
||||
| `--color-accent-dark` | `#b8461a` | Dunkle Variante |
|
||||
|
||||
### Status-Farben
|
||||
| Token | Hex/RGBA | Verwendung |
|
||||
|-------|----------|------------|
|
||||
| `--color-success` | `#4ade80` | Erfolg-Meldungen |
|
||||
| `--color-success-bg` | `rgba(74, 222, 128, 0.08)` | Erfolg-Hintergrund |
|
||||
| `--color-error` | `#f87171` | Fehler, Pflichtfelder |
|
||||
| `--color-error-bg` | `rgba(248, 113, 113, 0.08)` | Fehler-Hintergrund |
|
||||
| `--color-warning` | `#fbbf24` | Warnungen |
|
||||
| `--color-info` | `#60a5fa` | Info |
|
||||
|
||||
### FARB-REGELN
|
||||
- **Orange NIEMALS als große Fläche** – nur für Buttons, Badges, kleine Text-Highlights, Border
|
||||
- **Hintergründe IMMER Grautöne** (#131313 bis #2d2d2d)
|
||||
- **Text IMMER `var(--text)` (#ffffff) oder `var(--text-muted)` (#d4d4d4)**
|
||||
- **Sekundärer Text `var(--secondary)` (#656565)** für sehr gedimmte Infos
|
||||
- **Inline Styles mit CSS Variablen:** `style="color: var(--text)"`, `style="background: var(--panel)"`
|
||||
|
||||
---
|
||||
|
||||
## 2. Typography
|
||||
- **Font Family:** Inter (Google Fonts), Weights: 400, 500, 600, 700, 800
|
||||
- **Größen:** Tailwind Utilities (`text-xs`, `text-sm`, `text-base`, `text-lg`, `text-xl`, `text-2xl`, `text-3xl`, `text-4xl`, `text-5xl`, `text-6xl`)
|
||||
- **Headings:** `font-bold` oder `font-semibold`, `leading-tight` für große Headlines
|
||||
- **Body:** `leading-relaxed` für längere Texte
|
||||
- **Line Height:** 1.6 (Body), 1.1-1.3 (Headings)
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout-System
|
||||
|
||||
### Container
|
||||
- **Max-Width:** `max-w-7xl` (1280px)
|
||||
- **Padding:** `px-4 sm:px-6 lg:px-8`
|
||||
- **Center:** `mx-auto`
|
||||
|
||||
### Sections
|
||||
- **Vertical Padding:** `py-12 sm:py-16` (Standard), `py-16 sm:py-20` (große Sections), `py-20 sm:py-32` (Hero)
|
||||
- **Background Alternation:** `var(--bg)` → `var(--panel)` → `var(--bg)` für visuelle Trennung
|
||||
- **Section Dividers:** `.hms-section-divider` (subtiler Gradient)
|
||||
|
||||
### Grid
|
||||
- **Cards Grid:** `grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6`
|
||||
- **Speaker Grid:** `.hms-speaker-grid` (auto-fill, minmax 160px)
|
||||
- **Gallery Grid:** `.hms-gallery` (auto-fill, minmax 300px)
|
||||
- **2-Spalten Layout:** `grid lg:grid-cols-2 gap-8` oder `gap-12`
|
||||
|
||||
### Responsive Breakpoints
|
||||
- `sm:` 640px (Tablet Portrait)
|
||||
- `md:` 768px (Desktop Nav visible, Mobile Burger hidden)
|
||||
- `lg:` 1024px (Desktop, 3-4 Column Grids)
|
||||
- **Mobile-First:** Alle Layouts starten 1-Spalte
|
||||
|
||||
---
|
||||
|
||||
## 4. CSS Komponenten-Klassen (hms-*)
|
||||
|
||||
### ÜBERSICHT: Alle hms-* Klassen aus prototype-style.css
|
||||
Diese Klassen sind in `assets/css/main.css` definiert und MÜSSEN verwendet werden.
|
||||
Keine eigenen Klassen erfinden!
|
||||
|
||||
### Buttons
|
||||
```html
|
||||
<!-- Primary: Orange bg, white text -->
|
||||
<button class="hms-btn hms-btn-primary">Button Text</button>
|
||||
|
||||
<!-- Secondary: Surface bg, muted text, border -->
|
||||
<button class="hms-btn hms-btn-secondary">Button Text</button>
|
||||
|
||||
<!-- Ghost: Transparent, muted text -->
|
||||
<button class="hms-btn hms-btn-ghost">Button Text</button>
|
||||
```
|
||||
- **Padding:** `px-8 py-4` für große Buttons, `px-4 py-2` für kleine, `px-3 py-1.5` für Mini
|
||||
- **Font:** `font-semibold` oder `font-medium`, `text-sm` für Standard
|
||||
- **Hover-Effekte:** Primary → box-shadow glow + translateY(-1px); Secondary → border accent
|
||||
- **Disabled:** `--secondary` bg, `cursor: not-allowed`
|
||||
|
||||
### Cards
|
||||
```html
|
||||
<div class="hms-card p-6">
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text)">Title</h3>
|
||||
<p class="text-sm leading-relaxed" style="color: var(--text-muted)">Description</p>
|
||||
</div>
|
||||
```
|
||||
- **Background:** `var(--panel)` (#1a1a1a)
|
||||
- **Border:** `1px solid var(--border)` (#444444a8)
|
||||
- **Border-Radius:** `4px` (--radius-lg)
|
||||
- **Hover:** Box-shadow wird stärker, Border wird heller
|
||||
|
||||
### Equipment Cards
|
||||
```html
|
||||
<div class="hms-eq-card" @click="..." role="article" tabindex="0">
|
||||
<div class="aspect-[4/3] flex items-center justify-center relative overflow-hidden" style="background: var(--surface)">
|
||||
<img v-if="item.image" :src="item.image" class="absolute inset-0 w-full h-full object-cover" />
|
||||
<div v-else class="text-4xl" style="color: var(--secondary)">📦</div>
|
||||
<span v-if="item.category" class="hms-badge hms-badge-primary absolute top-3 left-3">{{ item.category }}</span>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-sm mb-1 truncate" style="color: var(--text)">{{ item.name }}</h3>
|
||||
<p class="text-xs mb-3 line-clamp-2" style="color: var(--secondary)">{{ item.description }}</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs" style="color: var(--secondary)">{{ item.code }}</span>
|
||||
<button class="hms-btn hms-btn-ghost text-xs px-3 py-1.5" style="color: var(--color-accent)">+ Hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Badges
|
||||
```html
|
||||
<span class="hms-badge hms-badge-primary">Badge Text</span>
|
||||
<span class="hms-badge hms-badge-gray">Gray Badge</span>
|
||||
<span class="hms-badge hms-badge-success">Success</span>
|
||||
<span class="hms-badge hms-badge-error">Error</span>
|
||||
```
|
||||
- **Border-Radius:** `9999px` (pill shape)
|
||||
- **Primary:** Orange bg (12% alpha), orange text, orange border (20% alpha)
|
||||
|
||||
### Inputs
|
||||
```html
|
||||
<input class="hms-input" type="text" placeholder="..." />
|
||||
<input class="hms-input hms-input-error" /> <!-- mit Fehler -->
|
||||
<select class="hms-input"><option>...</option></select>
|
||||
<textarea class="hms-input" rows="5"></textarea>
|
||||
```
|
||||
- **Background:** `var(--surface)` (#212121)
|
||||
- **Border:** `1px solid var(--border-strong)` (#555555)
|
||||
- **Focus:** Orange border + subtle orange box-shadow
|
||||
- **Error:** Red border + red box-shadow
|
||||
- **Padding:** `var(--space-md) var(--space-lg)`
|
||||
|
||||
### Filter Chips
|
||||
```html
|
||||
<button class="hms-chip" :class="{ active: isActive }">Filter</button>
|
||||
```
|
||||
- **Inactive:** Surface bg, muted text, border
|
||||
- **Active:** Orange bg, white text, orange border
|
||||
|
||||
### Navigation Buttons
|
||||
```html
|
||||
<button class="hms-nav-btn px-4 py-2 rounded-lg text-sm font-medium"
|
||||
:style="isActive ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }"
|
||||
:aria-current="isActive ? 'page' : undefined">
|
||||
Nav Item
|
||||
</button>
|
||||
```
|
||||
- **Hover:** Orange text + accent-light bg + Unterstrich-Animation
|
||||
- **Active:** Orange text + accent-light bg
|
||||
|
||||
### Skeleton Loading
|
||||
```html
|
||||
<div class="hms-skeleton h-4 w-3/4"></div>
|
||||
```
|
||||
- Shimmer Animation von `prototype-style.css`
|
||||
|
||||
### Spinner
|
||||
```html
|
||||
<span class="hms-spinner" style="width:18px;height:18px;border-width:2px"></span>
|
||||
```
|
||||
|
||||
### Icon Circle (Service Icons)
|
||||
```html
|
||||
<div class="hms-icon-circle mb-4"><span aria-hidden="true">🔊</span></div>
|
||||
```
|
||||
- 48x48px, surface bg, border, centered content
|
||||
|
||||
### Hero
|
||||
```html
|
||||
<section class="hms-hero py-20 sm:py-32">
|
||||
<div class="hms-hero-bg" style="background-image: url('...')"></div>
|
||||
<div class="hms-hero-overlay"></div>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<!-- Content -->
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
- **hms-hero-bg:** Absolute, background-size cover, opacity 0.35
|
||||
- **hms-hero-overlay:** Linear gradient von rgba(19,19,19,0.6) zu var(--bg)
|
||||
- **Content:** `relative z-10` über Overlay
|
||||
|
||||
---
|
||||
|
||||
## 5. Inline Style Pattern
|
||||
|
||||
### REGEL: Inline Styles verwenden IMMER CSS Variablen
|
||||
```html
|
||||
<!-- RICHTIG -->
|
||||
<h1 style="color: var(--text)">Title</h1>
|
||||
<p style="color: var(--text-muted)">Text</p>
|
||||
<div :style="{ background: 'var(--panel)', borderColor: 'var(--border)' }">
|
||||
|
||||
<!-- FALSCH -->
|
||||
<h1 style="color: #ffffff">Title</h1>
|
||||
<p style="color: #d4d4d4">Text</p>
|
||||
```
|
||||
|
||||
### Häufige Inline Styles aus dem Prototyp
|
||||
- `style="color: var(--text)"` – Haupttext
|
||||
- `style="color: var(--text-muted)"` – Gedimmter Text
|
||||
- `style="color: var(--secondary)"` – Sehr gedimmter Text
|
||||
- `style="color: var(--color-accent)"` – Orange Akzent
|
||||
- `style="background: var(--panel)"` – Panel BG
|
||||
- `style="background: var(--bg)"` – Seiten BG
|
||||
- `style="background: var(--surface)"` – Surface BG
|
||||
- `:style="{ borderColor: 'var(--border)' }"` – Border Color
|
||||
- `:style="{ background: 'var(--color-success-bg)', color: 'var(--color-success)' }"` – Success State
|
||||
|
||||
---
|
||||
|
||||
## 6. Komponenten-Struktur
|
||||
|
||||
### Bestehende Komponenten (NICHT neu erfinden)
|
||||
| Komponente | Datei | Zweck |
|
||||
|---|---|---|
|
||||
| HmsLogo | `components/HmsLogo.vue` | Echtes SVG Logo (H + orange Border) |
|
||||
| SpeakerIcon | `components/SpeakerIcon.vue` | 6 Lautsprecher-Typ SVG Icons |
|
||||
| AppHeader | `components/AppHeader.vue` | Sticky Header, Nav, Cart, Mobile Burger |
|
||||
| AppFooter | `components/AppFooter.vue` | 4-Spalten Footer |
|
||||
| ServiceCard | `components/ServiceCard.vue` | Service Card mit Icon Circle |
|
||||
| EquipmentCard | `components/EquipmentCard.vue` | Equipment Card mit Badge, Image, Add-to-Cart |
|
||||
| LoadingSkeleton | `components/LoadingSkeleton.vue` | Skeleton Loading Cards |
|
||||
| EmptyState | `components/EmptyState.vue` | Empty State mit Icon + Action |
|
||||
| ErrorState | `components/ErrorState.vue` | Error State mit Retry |
|
||||
|
||||
### Neue Komponenten erstellen – Checklist
|
||||
1. CSS Klassen aus `prototype-style.css` verwenden (hms-*)
|
||||
2. Inline Styles mit CSS Variablen
|
||||
3. Tailwind Utilities für Layout/Spacing
|
||||
4. TypeScript mit `lang="ts"`
|
||||
5. `useHead()` für Meta Tags
|
||||
6. ARIA Attributes (role, aria-label, aria-current, etc.)
|
||||
7. Responsive (mobile-first, sm/md/lg breakpoints)
|
||||
|
||||
---
|
||||
|
||||
## 7. UX States (MUSS in jeder View implementiert)
|
||||
|
||||
### Loading State
|
||||
```html
|
||||
<loading-skeleton :count="6" />
|
||||
```
|
||||
|
||||
### Empty State
|
||||
```html
|
||||
<empty-state icon="🔍" title="Keine Ergebnisse" message="..." action-label="Zurück" @action="..." />
|
||||
```
|
||||
|
||||
### Error State
|
||||
```html
|
||||
<error-state title="Fehler" message="..." @retry="..." />
|
||||
```
|
||||
|
||||
### Success State
|
||||
```html
|
||||
<div class="text-center py-8" role="status">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4"
|
||||
:style="{ background: 'var(--color-success-bg)', color: 'var(--color-success)' }">
|
||||
<svg class="w-8 h-8" ...>✓</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text)">Erfolg</h3>
|
||||
<p class="text-sm mb-6" style="color: var(--secondary)">Nachricht</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Submitting State
|
||||
```html
|
||||
<button :disabled="submitting" class="hms-btn hms-btn-primary">
|
||||
<span v-if="submitting" class="hms-spinner" style="width:18px;height:18px;border-width:2px"></span>
|
||||
<span v-else>Senden</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Accessibility (WCAG 2.1 AA)
|
||||
|
||||
### MUSS-Regeln
|
||||
- **Skip Link:** `<a class="skip-link" href="#main">Zum Inhalt springen</a>` am Seitenanfang
|
||||
- **Semantic HTML:** `<header>`, `<main>`, `<footer>`, `<nav>`, `<section>`, `<article>`
|
||||
- **ARIA:** `role`, `aria-label`, `aria-current="page"`, `aria-expanded`, `aria-invalid`, `aria-describedby`, `role="alert"`, `role="status"`
|
||||
- **Focus Visible:** `*:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }`
|
||||
- **Form Labels:** Jedes Input hat `<label for="...">`, Pflichtfelder mit `<span style="color: var(--color-error)">*</span>`
|
||||
- **Heading Hierarchy:** h1 > h2 > h3, keine Levels überspringen
|
||||
- **Keyboard Navigation:** Alle interaktiven Elemente per Tab erreichbar, `tabindex="0"` auf klickbare Cards
|
||||
- **Alt Text:** Alle Bilder haben `alt="..."`
|
||||
- `aria-hidden="true"` auf dekorativen Icons
|
||||
- `loading="lazy"` auf alle Bilder
|
||||
|
||||
---
|
||||
|
||||
## 9. Meta Tags & SEO
|
||||
|
||||
### MUSS pro Seite
|
||||
```typescript
|
||||
useHead({
|
||||
title: 'Seite – HMS Licht & Ton',
|
||||
meta: [
|
||||
{ name: 'robots', content: 'noindex, nofollow, noarchive, nosnippet' },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### JSON-LD (nur Home)
|
||||
- `LocalBusiness` Schema mit Adresse, Öffnungszeiten, Services
|
||||
|
||||
### OpenGraph
|
||||
- `og:type`, `og:title`, `og:description`, `og:locale`, `og:site_name`, `og:url`
|
||||
|
||||
### REGELN
|
||||
- **noindex** auf ALLEN Seiten (Domain nicht öffentlich indexiert)
|
||||
- **KEINE sitemap.xml**
|
||||
- Inter Font via Google Fonts preconnect
|
||||
- `theme-color: #EC6925` (nur im Head, nicht als Design-Fläche!)
|
||||
|
||||
---
|
||||
|
||||
## 10. Bilder
|
||||
|
||||
### Hero Background
|
||||
- Unsplash Konzert-Bild: `background-image: url('https://images.unsplash.com/photo-1470229722913-7c0e2dbbafd3?w=1920&q=80')`
|
||||
- **Opacity:** 0.35 (über `.hms-hero-bg`)
|
||||
- **Overlay:** Linear gradient zu `var(--bg)`
|
||||
|
||||
### Referenzen
|
||||
- 9 echte HMS-Fotos: `img/ref1.jpg` bis `img/ref9.jpg`
|
||||
- **Hover:** `group-hover:scale-110` (Zoom-Effekt)
|
||||
- **Gradient Overlay:** `bg-gradient-to-t from-black/50 to-transparent` bei Hover
|
||||
|
||||
### Equipment
|
||||
- Platzhalter `📦` Emoji wenn kein Bild vorhanden
|
||||
- `loading="lazy"` auf allen Bildern
|
||||
|
||||
### About Section
|
||||
- Unsplash Setup-Bild: `https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=800&q=80`
|
||||
|
||||
---
|
||||
|
||||
## 11. Spacing & Radius
|
||||
|
||||
### Spacing Tokens
|
||||
| Token | Wert |
|
||||
|-------|-------|
|
||||
| `--space-xs` | 0.25rem (4px) |
|
||||
| `--space-sm` | 0.5rem (8px) |
|
||||
| `--space-md` | 1rem (16px) |
|
||||
| `--space-lg` | 1.5rem (24px) |
|
||||
| `--space-xl` | 2rem (32px) |
|
||||
| `--space-2xl` | 3rem (48px) |
|
||||
| `--space-3xl` | 4rem (64px) |
|
||||
| `--space-4xl` | 6rem (96px) |
|
||||
|
||||
### Border Radius (MINIMAL!)
|
||||
| Token | Wert |
|
||||
|-------|-------|
|
||||
| `--radius-sm` | 2px |
|
||||
| `--radius-md` | 3px |
|
||||
| `--radius-lg` | 4px |
|
||||
| `--radius-xl` | 4px |
|
||||
| `--radius-full` | 9999px (nur Badges, Pills) |
|
||||
|
||||
### Shadows
|
||||
| Token | Wert |
|
||||
|-------|-------|
|
||||
| `--shadow-sm` | `0 1px 2px 0 rgb(0 0 0 / 0.3)` |
|
||||
| `--shadow-md` | `0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3)` |
|
||||
| `--shadow-lg` | `0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3)` |
|
||||
| `--shadow-xl` | `0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.3)` |
|
||||
|
||||
---
|
||||
|
||||
## 12. Transitions
|
||||
| Token | Dauer |
|
||||
|-------|-------|
|
||||
| `--transition-fast` | 150ms ease |
|
||||
| `--transition-base` | 250ms ease |
|
||||
| `--transition-slow` | 400ms ease |
|
||||
|
||||
---
|
||||
|
||||
## 13. Build & Test
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
cd frontend && npm run build # Build (MUSS 0 errors)
|
||||
cd frontend && npx vitest run # Unit Tests (MUSS alle pass)
|
||||
cd frontend && npx playwright test --grep 'Pattern' # E2E Tests
|
||||
curl -s http://localhost:3000/ # Smoke Test
|
||||
```
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
cd backend && python -m pytest tests/ -v --cov=app # Tests (MUSS alle pass)
|
||||
curl -s http://localhost:8000/api/health # Health Check
|
||||
```
|
||||
|
||||
### Deployment
|
||||
- Server: coolify-01 (46.225.91.159)
|
||||
- Pfad: `/data/hms-licht-ton/`
|
||||
- Branch: `feature/T04-rentman-integration` (oder `main` nach Merge)
|
||||
- Docker Compose: `docker compose up -d`
|
||||
- Domain: `https://hms.media-on.de` (Traefik + Let's Encrypt)
|
||||
|
||||
---
|
||||
|
||||
## 14. VERBOTEN
|
||||
- ❌ Eigene CSS Klassen erfinden (nur `hms-*` verwenden)
|
||||
- ❌ Generische `.btn-primary` oder `.nav-link` Klassen
|
||||
- ❌ Hardcoded Hex-Werte in Inline Styles (immer `var(--...)`)
|
||||
- ❌ Fake-Logo (immer echtes SVG aus `assets/logo.svg`)
|
||||
- ❌ Große Orange-Flächen
|
||||
- ❌ Abgerundete Ecken > 4px (außer Badges/Pills = 9999px)
|
||||
- ❌ Client-only rendering für SSR Pages (`/mietkatalog`, `/mietkatalog/**`)
|
||||
- ❌ Fehlende ARIA Attributes
|
||||
- ❌ Fehlende UX States (Loading, Empty, Error, Success)
|
||||
- ❌ Fehlendes `noindex` Meta Tag
|
||||
- ❌ Eigene Templates die nicht dem Prototyp entsprechen
|
||||
|
||||
---
|
||||
|
||||
## 15. Referenz-Dateien
|
||||
|
||||
| Datei | Zweck |
|
||||
|---|---|
|
||||
| `docs/prototype-app.js` | Original Vue 3 SPA Prototyp (761 Zeilen, 19 Komponenten) |
|
||||
| `docs/prototype-index.html` | Original HTML mit Meta Tags, JSON-LD |
|
||||
| `frontend/assets/css/prototype-style.css` | Original CSS mit allen hms-* Klassen (13KB) |
|
||||
| `frontend/assets/css/main.css` | Aktive CSS (enthält prototype-style.css + Tailwind directives) |
|
||||
| `frontend/assets/logo.svg` | Echtes HMS Logo (503 bytes, weißes H + orange Border) |
|
||||
| `frontend/public/logo.svg` | Static copy für Nuxt public dir |
|
||||
| `docs/ui_design.md` | Original UI Design Dokumentation |
|
||||
| `docs/design-rules.md` | DIES DATEI – Design Richtlinien für KI-Agenten |
|
||||
|
||||
---
|
||||
|
||||
## 16. Schnell-Referenz: Neue Seite erstellen
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold mb-2" style="color: var(--text)">Seiten-Titel</h1>
|
||||
<p class="mb-8" style="color: var(--secondary)">Untertitel</p>
|
||||
|
||||
<!-- Loading -->
|
||||
<loading-skeleton v-if="loading" :count="6" />
|
||||
|
||||
<!-- Error -->
|
||||
<error-state v-else-if="error" title="Fehler" message="..." @retry="loadData" />
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div v-for="item in items" :key="item.id" class="hms-card p-6">
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text)">{{ item.title }}</h3>
|
||||
<p class="text-sm leading-relaxed" style="color: var(--text-muted)">{{ item.description }}</p>
|
||||
<button class="hms-btn hms-btn-primary mt-4">Aktion</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<empty-state v-if="!loading && !error && items.length === 0"
|
||||
icon="🔍" title="Keine Daten" message="..." action-label="Zurück" @action="..." />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'Seite – HMS Licht & Ton',
|
||||
meta: [{ name: 'robots', content: 'noindex, nofollow, noarchive, nosnippet' }],
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Dieses Dokument ist die verbindliche Design-Richtlinie für alle Erweiterungen der HMS Licht & Ton Website.*
|
||||
Vendored
+761
@@ -0,0 +1,761 @@
|
||||
/* ============================================
|
||||
HMS Licht & Ton – Vue 3 SPA Prototype v4
|
||||
Dark Theme + Subtle Orange + Images + Speaker Grid
|
||||
============================================ */
|
||||
|
||||
const { createApp, ref, reactive, computed, onMounted, defineComponent, h } = Vue;
|
||||
|
||||
// ===== Logo Component =====
|
||||
const HmsLogo = defineComponent({
|
||||
name: 'HmsLogo',
|
||||
props: { size: { type: Number, default: 40 } },
|
||||
setup(props) {
|
||||
return () => h('svg', {
|
||||
class: 'hms-logo-svg', viewBox: '0 0 200 200',
|
||||
style: `height:${props.size}px;width:auto`,
|
||||
'aria-label': 'HMS Licht & Ton Logo', role: 'img'
|
||||
}, [
|
||||
h('path', { fill: 'currentColor', d: 'M166.266,172.872h-37.687v-60.718H74.226v60.718H36.539V26.956h37.687v56.335h54.354V26.956h37.687V172.872z' }),
|
||||
h('rect', { x: '23.598', y: '15.343', fill: 'none', stroke: '#EC6925', 'stroke-width': '15.1525', 'stroke-miterlimit': '10', width: '155.612', height: '168.874' })
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// ===== Speaker SVG Icon Component =====
|
||||
const SpeakerIcon = defineComponent({
|
||||
name: 'SpeakerIcon',
|
||||
props: { type: { type: String, default: 'linearray' } },
|
||||
setup(props) {
|
||||
const icons = {
|
||||
linearray: '<rect x="8" y="2" width="16" height="40" rx="2"/><circle cx="16" cy="10" r="3"/><circle cx="16" cy="22" r="4"/><circle cx="16" cy="34" r="3"/>',
|
||||
subwoofer: '<rect x="4" y="8" width="24" height="20" rx="3"/><circle cx="16" cy="18" r="7"/><circle cx="16" cy="18" r="3"/>',
|
||||
monitor: '<path d="M4 6h20v14H4z"/><path d="M8 24h12"/><circle cx="14" cy="13" r="3"/>',
|
||||
pointsource: '<circle cx="16" cy="16" r="12"/><circle cx="16" cy="16" r="6"/><circle cx="16" cy="16" r="2"/>',
|
||||
column: '<rect x="10" y="2" width="12" height="44" rx="2"/><circle cx="16" cy="8" r="2"/><circle cx="16" cy="16" r="2"/><circle cx="16" cy="24" r="2"/><circle cx="16" cy="32" r="2"/><circle cx="16" cy="40" r="2"/>',
|
||||
movinghead: '<rect x="10" y="4" width="12" height="16" rx="2"/><path d="M16 20v8"/><rect x="8" y="28" width="16" height="4" rx="1"/>'
|
||||
};
|
||||
const svgStr = computed(() => '<svg class="hms-speaker-icon" viewBox="0 0 32 48" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">' + (icons[props.type] || icons.linearray) + '</svg>');
|
||||
return { svgStr };
|
||||
},
|
||||
template: '<span class="hms-speaker-icon-wrap" v-html="svgStr"></span>'
|
||||
});
|
||||
|
||||
// ===== Header =====
|
||||
const AppHeader = defineComponent({
|
||||
name: 'AppHeader',
|
||||
setup() {
|
||||
const mobileMenuOpen = ref(false);
|
||||
const currentRoute = ref(window.location.hash || '#/');
|
||||
const navItems = [
|
||||
{ label: 'Home', route: '#/' },
|
||||
{ label: 'Referenzen', route: '#/referenzen' },
|
||||
{ label: 'Mietkatalog', route: '#/mietkatalog' },
|
||||
{ label: 'Kontakt', route: '#/kontakt' }
|
||||
];
|
||||
function navigate(route) { window.location.hash = route; mobileMenuOpen.value = false; window.scrollTo(0, 0); }
|
||||
function isActive(route) { return currentRoute.value === route || (route !== '#/' && currentRoute.value.startsWith(route)); }
|
||||
window.addEventListener('hashchange', () => { currentRoute.value = window.location.hash || '#/'; mobileMenuOpen.value = false; });
|
||||
return { mobileMenuOpen, currentRoute, navItems, navigate, isActive };
|
||||
},
|
||||
template: `
|
||||
<header class="hms-header" role="banner">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between" :style="{ height: 'var(--header-height)' }">
|
||||
<a href="#/" @click.prevent="navigate('#/')" class="flex items-center gap-2" style="color: var(--text)" aria-label="HMS Licht & Ton Startseite">
|
||||
<hms-logo :size="40" />
|
||||
<div class="hidden sm:block leading-tight">
|
||||
<div class="font-bold text-base" style="color: var(--text)">HMS Licht & Ton</div>
|
||||
<div class="text-xs" style="color: var(--secondary)">Veranstaltungstechnik</div>
|
||||
</div>
|
||||
</a>
|
||||
<nav class="hidden md:flex items-center gap-1" role="navigation" aria-label="Hauptnavigation">
|
||||
<button v-for="item in navItems" :key="item.route" @click="navigate(item.route)"
|
||||
class="hms-nav-btn px-4 py-2 rounded-lg text-sm font-medium"
|
||||
:style="isActive(item.route) ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }"
|
||||
:aria-current="isActive(item.route) ? 'page' : undefined">
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<button @click="navigate('#/warenkorb')" class="hms-btn hms-btn-primary text-sm ml-2 px-4 py-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
Warenkorb
|
||||
</button>
|
||||
</nav>
|
||||
<button @click="mobileMenuOpen = !mobileMenuOpen" class="md:hidden p-2 rounded-lg" :style="{ color: 'var(--text-muted)' }" :aria-expanded="mobileMenuOpen" aria-label="Menü öffnen/schließen">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-if="!mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="mobile-menu">
|
||||
<nav v-if="mobileMenuOpen" id="mobile-menu" class="md:hidden border-t" :style="{ borderColor: 'var(--border)', background: 'var(--panel)' }" role="navigation" aria-label="Mobile Navigation">
|
||||
<div class="px-4 py-3 space-y-1">
|
||||
<button v-for="item in navItems" :key="item.route" @click="navigate(item.route)"
|
||||
class="hms-nav-btn block w-full text-left px-4 py-3 rounded-lg text-sm font-medium"
|
||||
:style="isActive(item.route) ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }">
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<button @click="navigate('#/warenkorb')" class="block w-full text-left px-4 py-3 rounded-lg text-sm font-medium text-white" style="background: var(--color-accent)">🛒 Warenkorb</button>
|
||||
</div>
|
||||
</nav>
|
||||
</transition>
|
||||
</header>
|
||||
`
|
||||
});
|
||||
|
||||
// ===== Footer =====
|
||||
const AppFooter = defineComponent({
|
||||
name: 'AppFooter',
|
||||
setup() {
|
||||
function navigate(route) { window.location.hash = route; window.scrollTo(0, 0); }
|
||||
const year = new Date().getFullYear();
|
||||
return { navigate, year };
|
||||
},
|
||||
template: `
|
||||
<footer class="hms-footer mt-16" role="contentinfo">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-4" style="color: var(--text)">
|
||||
<hms-logo :size="36" />
|
||||
<div><div class="font-bold text-sm">HMS Licht & Ton</div><div class="text-xs" style="color: var(--secondary)">Veranstaltungstechnik</div></div>
|
||||
</div>
|
||||
<p class="text-sm leading-relaxed" style="color: var(--secondary)">Hammerschmidt u. Mössle GbR – seit über 20 Jahren Ihr Partner für professionelle Veranstaltungstechnik in der Region Leipheim / Ellzee.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-4" style="color: var(--text)">Navigation</h3>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li><a href="#/" @click.prevent="navigate('#/')" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">Home</a></li>
|
||||
<li><a href="#/referenzen" @click.prevent="navigate('#/referenzen')" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">Referenzen</a></li>
|
||||
<li><a href="#/mietkatalog" @click.prevent="navigate('#/mietkatalog')" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">Mietkatalog</a></li>
|
||||
<li><a href="#/kontakt" @click.prevent="navigate('#/kontakt')" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">Kontakt</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-4" style="color: var(--text)">Kontakt</h3>
|
||||
<ul class="space-y-2 text-sm" style="color: var(--secondary)">
|
||||
<li>Grockelhofen 10, 89340 Leipheim</li>
|
||||
<li><a href="tel:+498221204433" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">+49 (0) 8221 / 204433</a></li>
|
||||
<li><a href="mailto:info@hms-licht-ton.de" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">info@hms-licht-ton.de</a></li>
|
||||
<li class="flex gap-3 pt-2">
|
||||
<a href="https://facebook.com" target="_blank" rel="noopener" aria-label="Facebook" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]"><svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg></a>
|
||||
<a href="https://instagram.com" target="_blank" rel="noopener" aria-label="Instagram" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]"><svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-4" style="color: var(--text)">Rechtliches</h3>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li><a href="#/impressum" @click.prevent="navigate('#/kontakt')" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">Impressum</a></li>
|
||||
<li><a href="#/dsgvo" @click.prevent="navigate('#/kontakt')" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">DSGVO</a></li>
|
||||
<li><a href="#/agb" @click.prevent="navigate('#/kontakt')" style="color: var(--secondary)" class="hover:text-[var(--color-accent)]">AGB Vermietung</a></li>
|
||||
<li><a href="#/admin" @click.prevent="navigate('#/admin')" style="color: var(--secondary)" class="hover:text-[var(--color-accent)] text-xs">Admin-Login</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t mt-8 pt-6 text-center text-xs" :style="{ borderColor: 'var(--border)', color: 'var(--secondary)' }">
|
||||
© {{ year }} Hammerschmidt u. Mössle GbR · Veranstaltungstechnik · Leipheim / Ellzee
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
`
|
||||
});
|
||||
|
||||
// ===== Service Card =====
|
||||
const ServiceCard = defineComponent({
|
||||
name: 'ServiceCard',
|
||||
props: { icon: String, title: String, description: String },
|
||||
template: `
|
||||
<div class="hms-card p-6">
|
||||
<div class="hms-icon-circle mb-4"><span aria-hidden="true">{{ icon }}</span></div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text)">{{ title }}</h3>
|
||||
<p class="text-sm leading-relaxed" style="color: var(--text-muted)">{{ description }}</p>
|
||||
</div>`
|
||||
});
|
||||
|
||||
// ===== Equipment Card =====
|
||||
const EquipmentCard = defineComponent({
|
||||
name: 'EquipmentCard',
|
||||
props: { item: Object },
|
||||
emits: ['click', 'add-to-cart'],
|
||||
template: `
|
||||
<div class="hms-eq-card" @click="$emit('click', item)" role="article" tabindex="0" @keydown.enter="$emit('click', item)">
|
||||
<div class="aspect-[4/3] flex items-center justify-center relative overflow-hidden" style="background: var(--surface)">
|
||||
<img v-if="item.image" :src="item.image" :alt="item.name" loading="lazy" class="absolute inset-0 w-full h-full object-cover" />
|
||||
<div v-else class="text-4xl" style="color: var(--secondary)">📦</div>
|
||||
<span v-if="item.category" class="hms-badge hms-badge-primary absolute top-3 left-3">{{ item.category }}</span>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-sm mb-1 truncate" style="color: var(--text)">{{ item.name }}</h3>
|
||||
<p class="text-xs mb-3 line-clamp-2" style="color: var(--secondary)">{{ item.description }}</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs" style="color: var(--secondary)">{{ item.code }}</span>
|
||||
<button @click.stop="$emit('add-to-cart', item)" class="hms-btn hms-btn-ghost text-xs px-3 py-1.5" style="color: var(--color-accent)" :aria-label="item.name + ' zum Warenkorb hinzufügen'">+ Hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
// ===== UX State Components =====
|
||||
const LoadingSkeleton = defineComponent({
|
||||
name: 'LoadingSkeleton',
|
||||
props: { count: { type: Number, default: 6 } },
|
||||
template: `
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6" aria-live="polite" aria-busy="true">
|
||||
<div v-for="i in count" :key="i" class="hms-card overflow-hidden">
|
||||
<div class="hms-skeleton aspect-[4/3]" style="border-radius:0"></div>
|
||||
<div class="p-4 space-y-3"><div class="hms-skeleton h-4 w-3/4"></div><div class="hms-skeleton h-3 w-full"></div><div class="hms-skeleton h-3 w-1/2"></div><div class="flex justify-between items-center pt-2"><div class="hms-skeleton h-3 w-20"></div><div class="hms-skeleton h-6 w-24" style="border-radius:var(--radius-md)"></div></div></div>
|
||||
</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
const EmptyState = defineComponent({
|
||||
name: 'EmptyState',
|
||||
props: { icon: { type: String, default: '🔍' }, title: String, message: String, actionLabel: String },
|
||||
emits: ['action'],
|
||||
template: `
|
||||
<div class="text-center py-16 px-4" role="status">
|
||||
<div class="text-6xl mb-4" aria-hidden="true">{{ icon }}</div>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text)">{{ title }}</h3>
|
||||
<p class="max-w-md mx-auto mb-6" style="color: var(--secondary)">{{ message }}</p>
|
||||
<button v-if="actionLabel" @click="$emit('action')" class="hms-btn hms-btn-primary">{{ actionLabel }}</button>
|
||||
</div>`
|
||||
});
|
||||
|
||||
const ErrorState = defineComponent({
|
||||
name: 'ErrorState',
|
||||
props: { title: { type: String, default: 'Ein Fehler ist aufgetreten' }, message: String },
|
||||
emits: ['retry'],
|
||||
template: `
|
||||
<div class="text-center py-16 px-4" role="alert">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" :style="{ background: 'var(--color-error-bg)', color: 'var(--color-error)' }">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text)">{{ title }}</h3>
|
||||
<p class="max-w-md mx-auto mb-6" style="color: var(--secondary)">{{ message }}</p>
|
||||
<button @click="$emit('retry')" class="hms-btn hms-btn-primary">Erneut versuchen</button>
|
||||
</div>`
|
||||
});
|
||||
|
||||
// ===== HOME PAGE with Hero Image + Speaker Grid =====
|
||||
const HomePage = defineComponent({
|
||||
name: 'HomePage',
|
||||
emits: ['navigate', 'add-to-cart'],
|
||||
setup(_, { emit }) {
|
||||
function navigate(route) { window.location.hash = route; window.scrollTo(0, 0); }
|
||||
const services = [
|
||||
{ icon: '🔊', title: 'Vermietung', description: 'Lautsprecher, Mischpulte, Lichtanlagen, Rigging und Komplett-Setups aus unserem 1.000+ Geräte umfassenden Mietlager in Ellzee. Geprüftes Equipment, sofort einsatzbereit.' },
|
||||
{ icon: '🛒', title: 'Verkauf', description: 'Neu- und Gebrauchtgeräte führender Hersteller mit Beratung, Inbetriebnahme und Service. Wir liefern nicht nur – wir konfigurieren Ihre Anlage einsatzfertig.' },
|
||||
{ icon: '👷', title: 'Personal', description: 'Qualifizierte Tontechniker, Lichttechniker und Rigger für Aufbau, Betrieb und Abbau. Mit TÜV-geprüfter Ausbildung und umfangreicher Praxiserfahrung.' },
|
||||
{ icon: '🚚', title: 'Transport', description: 'Eigene Transportfahrzeuge für sichere An- und Ablieferung. Inklusive Verladekonzept, Positionierung und Rückholung – kundengerecht terminiert.' },
|
||||
{ icon: '📦', title: 'Lagerung', description: 'Klimatisierte und trockene Lagerung für Equipment und Veranstaltungsbestände. Mit Bestandsmanagement und regelmäßiger Funktionsprüfung.' },
|
||||
{ icon: '🔧', title: 'Werkstatt', description: 'Herstellerübergreifende Reparatur und Wartung in unserer eigenen Werkstatt. Regelmäßige DGUV-Prüfungen und Kalibrierung für Ihren sicheren Betrieb.' },
|
||||
{ icon: '⚡', title: 'Installation', description: 'Festinstallationen in Schaufenstern, Lokalen und Veranstaltungsstätten. Von der Planung über die Elektroinstallation bis zur Abnahme – alles aus einer Hand.' },
|
||||
{ icon: '📅', title: 'Booking', description: 'Vermittlung von Künstlern und Technik-Personal für Ihre Veranstaltung. Mit unserem branchenweiten Netzwerk finden wir die passenden Professionals für Ihr Event.' }
|
||||
];
|
||||
const speakers = [
|
||||
{ type: 'linearray', name: 'L-Acoustics K2', category: 'Line Array' },
|
||||
{ type: 'subwoofer', name: 'L-Acoustics KS28', category: 'Subwoofer' },
|
||||
{ type: 'pointsource', name: 'd&b T10', category: 'Point Source' },
|
||||
{ type: 'column', name: 'd&b KSL8', category: 'Column Array' },
|
||||
{ type: 'monitor', name: 'd&b M4', category: 'Monitor' },
|
||||
{ type: 'movinghead', name: 'Robe Pointe', category: 'Moving Head' }
|
||||
];
|
||||
return { services, speakers, navigate };
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<!-- Hero with background image -->
|
||||
<section class="hms-hero py-20 sm:py-32" aria-labelledby="hero-title">
|
||||
<div class="hms-hero-bg" style="background-image: url('https://images.unsplash.com/photo-1470229722913-7c0e2dbbafd3?w=1920&q=80')"></div>
|
||||
<div class="hms-hero-overlay"></div>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div class="max-w-2xl">
|
||||
<span class="hms-badge hms-badge-primary mb-4">Seit 2003 · Über 20 Jahre Veranstaltungstechnik</span>
|
||||
<h1 id="hero-title" class="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight mb-6" style="color: var(--text)">
|
||||
Professionelle Technik<br><span style="color: var(--color-accent)">für Ihre Veranstaltung</span>
|
||||
</h1>
|
||||
<p class="text-lg sm:text-xl mb-8 max-w-xl" style="color: var(--text-muted)">
|
||||
Von der Firmenevent-Beschallung bis zur Open-Air-Großproduktion: HMS Licht & Ton liefert Tontechnik, Lichttechnik, Rigging und Komplettlösungen aus Leipheim und Ellzee.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<button @click="navigate('#/mietkatalog')" class="hms-btn hms-btn-primary text-base px-8 py-4">
|
||||
Mietkatalog öffnen
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
|
||||
</button>
|
||||
<button @click="navigate('#/kontakt')" class="hms-btn hms-btn-secondary text-base px-8 py-4">Beratung anfragen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Speaker Grid -->
|
||||
<section class="py-12 sm:py-16" :style="{ background: 'var(--bg)' }" aria-labelledby="speaker-title">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-8">
|
||||
<span class="text-sm font-semibold uppercase tracking-wider" style="color: var(--color-accent)">Equipment-Highlights</span>
|
||||
<h2 id="speaker-title" class="text-2xl sm:text-3xl font-bold mt-1" style="color: var(--text)">Lautsprecher & Strahler aus unserem Mietlager</h2>
|
||||
</div>
|
||||
<div class="hms-speaker-grid">
|
||||
<div v-for="sp in speakers" :key="sp.name" class="hms-speaker-item" @click="navigate('#/mietkatalog')" role="button" tabindex="0" @keydown.enter="navigate('#/mietkatalog')">
|
||||
<speaker-icon :type="sp.type" />
|
||||
<div class="hms-speaker-name">{{ sp.name }}</div>
|
||||
<div class="hms-speaker-type">{{ sp.category }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Über uns -->
|
||||
<section class="py-16 sm:py-20" :style="{ background: 'var(--panel)' }" aria-labelledby="about-title">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<span class="text-sm font-semibold uppercase tracking-wider mb-2" style="color: var(--color-accent)">Unternehmen</span>
|
||||
<h2 id="about-title" class="text-3xl sm:text-4xl font-bold mb-6" style="color: var(--text)">Ihr Technik-Partner mit Erfahrung</h2>
|
||||
<p class="leading-relaxed mb-4" style="color: var(--text-muted)">Die <strong style="color: var(--text)">Hammerschmidt u. Mössle GbR</strong> ist seit 2003 Ihr zuverlässiger Partner für Veranstaltungstechnik in der Region Leipheim / Ellzee und im gesamten süddeutschen Raum.</p>
|
||||
<p class="leading-relaxed mb-4" style="color: var(--text-muted)">Mit einem Mietlager von über 1.000 Geräten, einer eigenen Werkstatt und erfahrenem Personal realisieren wir Veranstaltungen jeder Größenordnung.</p>
|
||||
<div class="grid grid-cols-3 gap-4 mt-8">
|
||||
<div class="text-center"><div class="text-3xl font-bold" style="color: var(--color-accent)">20+</div><div class="text-sm" style="color: var(--secondary)">Jahre Erfahrung</div></div>
|
||||
<div class="text-center"><div class="text-3xl font-bold" style="color: var(--color-accent)">1.000+</div><div class="text-sm" style="color: var(--secondary)">Geräte im Lager</div></div>
|
||||
<div class="text-center"><div class="text-3xl font-bold" style="color: var(--color-accent)">2</div><div class="text-sm" style="color: var(--secondary)">Standorte</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div class="aspect-[4/3] rounded-lg overflow-hidden shadow-xl">
|
||||
<img src="https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=800&q=80" alt="Veranstaltungstechnik Setup" loading="lazy" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Leistungen -->
|
||||
<section class="py-16 sm:py-20" :style="{ background: 'var(--bg)' }" aria-labelledby="services-title">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<span class="text-sm font-semibold uppercase tracking-wider mb-2" style="color: var(--color-accent)">Leistungen</span>
|
||||
<h2 id="services-title" class="text-3xl sm:text-4xl font-bold" style="color: var(--text)">Von der Vermietung bis zur Komplettbetreuung</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<service-card v-for="s in services" :key="s.title" :icon="s.icon" :title="s.title" :description="s.description" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="py-16 sm:py-20" :style="{ background: 'var(--panel)' }" aria-labelledby="rental-title">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="rounded-lg p-8 sm:p-12 lg:p-16 text-center" style="background: var(--bg); border: 1px solid var(--border)">
|
||||
<h2 id="rental-title" class="text-3xl sm:text-4xl font-bold mb-4" style="color: var(--text)">Online-Mietkatalog</h2>
|
||||
<p class="max-w-2xl mx-auto mb-8" style="color: var(--text-muted)">Durchsuchen Sie unser Equipment-Sortiment und stellen Sie Ihre Mietanfrage direkt online zusammen.</p>
|
||||
<button @click="navigate('#/mietkatalog')" class="hms-btn hms-btn-primary text-base px-8 py-4">
|
||||
Katalog durchsuchen
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>`
|
||||
});
|
||||
|
||||
// ===== REFERENZEN PAGE with real HMS images =====
|
||||
const ReferenzenPage = defineComponent({
|
||||
name: 'ReferenzenPage',
|
||||
setup() {
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
const filter = ref('alle');
|
||||
const categories = ['alle', 'Open-Air', 'Indoor', 'Rigging', 'Event'];
|
||||
const allImages = [
|
||||
{ id: 1, title: 'Open-Air Veranstaltung', category: 'Open-Air', date: '2017', img: 'img/ref1.jpg' },
|
||||
{ id: 2, title: 'Bühnenaufbau', category: 'Rigging', date: '2016', img: 'img/ref2.jpg' },
|
||||
{ id: 3, title: 'Donautal Radelspass', category: 'Event', date: '2019', img: 'img/ref3.jpg' },
|
||||
{ id: 4, title: 'Traversenaufbau', category: 'Rigging', date: '2019', img: 'img/ref4.jpg' },
|
||||
{ id: 5, title: 'Veranstaltungstechnik vor Ort', category: 'Event', date: '2019', img: 'img/ref5.jpg' },
|
||||
{ id: 6, title: 'Indoor Beschallung', category: 'Indoor', date: '2016', img: 'img/ref6.jpg' },
|
||||
{ id: 7, title: 'Bühnentechnik Setup', category: 'Rigging', date: '2019', img: 'img/ref7.jpg' },
|
||||
{ id: 8, title: 'Event-Aufbau', category: 'Event', date: '2019', img: 'img/ref8.jpg' },
|
||||
{ id: 9, title: 'Großbühne Aufbau', category: 'Open-Air', date: '2016', img: 'img/ref9.jpg' }
|
||||
];
|
||||
const filteredImages = computed(() => {
|
||||
if (filter.value === 'alle') return allImages;
|
||||
return allImages.filter(i => i.category === filter.value);
|
||||
});
|
||||
onMounted(() => { setTimeout(() => { loading.value = false; }, 1000); });
|
||||
function retry() { loading.value = true; error.value = false; setTimeout(() => { loading.value = false; }, 800); }
|
||||
return { loading, error, filter, categories, filteredImages, retry };
|
||||
},
|
||||
template: `
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold mb-2" style="color: var(--text)">Referenzen</h1>
|
||||
<p class="mb-8" style="color: var(--secondary)">Ausgewählte Projekte aus unserer Veranstaltungstechnik-Praxis.</p>
|
||||
<div class="flex flex-wrap gap-2 mb-8" role="tablist" aria-label="Referenz-Filter">
|
||||
<button v-for="cat in categories" :key="cat" @click="filter = cat" :class="['hms-chip', filter === cat ? 'active' : '']" role="tab" :aria-selected="filter === cat">{{ cat === 'alle' ? 'Alle' : cat }}</button>
|
||||
</div>
|
||||
<div v-if="loading">
|
||||
<div class="hms-gallery">
|
||||
<div v-for="i in 6" :key="i" class="hms-card overflow-hidden">
|
||||
<div class="hms-skeleton aspect-[4/3]" style="border-radius:0"></div>
|
||||
<div class="p-4 space-y-2"><div class="hms-skeleton h-4 w-3/4"></div><div class="hms-skeleton h-3 w-1/3"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<error-state v-else-if="error" title="Referenzen konnten nicht geladen werden" message="Bitte versuchen Sie es in Kürze erneut." @retry="retry" />
|
||||
<div v-else class="hms-gallery">
|
||||
<article v-for="img in filteredImages" :key="img.id" class="hms-card overflow-hidden group cursor-pointer">
|
||||
<div class="aspect-[4/3] relative overflow-hidden">
|
||||
<img :src="img.img" :alt="img.title" loading="lazy" class="absolute inset-0 w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<span class="hms-badge hms-badge-primary absolute top-3 left-3">{{ img.category }}</span>
|
||||
</div>
|
||||
<div class="p-4"><h3 class="font-semibold text-sm" style="color: var(--text)">{{ img.title }}</h3><p class="text-xs mt-1" style="color: var(--secondary)">{{ img.date }}</p></div>
|
||||
</article>
|
||||
</div>
|
||||
<empty-state v-if="!loading && !error && filteredImages.length === 0" icon="📭" title="Keine Referenzen" message="Für diese Kategorie liegen aktuell keine Referenzen vor." action-label="Alle anzeigen" @action="filter = 'alle'" />
|
||||
</div>`
|
||||
});
|
||||
|
||||
// ===== KONTAKT PAGE =====
|
||||
const KontaktPage = defineComponent({
|
||||
name: 'KontaktPage',
|
||||
setup() {
|
||||
const form = reactive({ name: '', email: '', phone: '', subject: '', message: '', privacy: false });
|
||||
const errors = reactive({});
|
||||
const submitted = ref(false);
|
||||
const submitting = ref(false);
|
||||
function validate() {
|
||||
const e = {};
|
||||
if (!form.name.trim()) e.name = 'Name ist erforderlich';
|
||||
if (!form.email.trim()) e.email = 'E-Mail ist erforderlich';
|
||||
else if (!/^\S+@\S+\.\S+$/.test(form.email)) e.email = 'Ungültige E-Mail-Adresse';
|
||||
if (!form.message.trim()) e.message = 'Nachricht ist erforderlich';
|
||||
if (!form.privacy) e.privacy = 'Bitte stimmen Sie der Datenschutzerklärung zu';
|
||||
Object.keys(errors).forEach(k => delete errors[k]); Object.assign(errors, e);
|
||||
return Object.keys(e).length === 0;
|
||||
}
|
||||
function submit() { if (!validate()) return; submitting.value = true; setTimeout(() => { submitting.value = false; submitted.value = true; }, 1500); }
|
||||
function resetForm() { Object.assign(form, { name: '', email: '', phone: '', subject: '', message: '', privacy: false }); submitted.value = false; }
|
||||
return { form, errors, submitted, submitting, validate, submit, resetForm };
|
||||
},
|
||||
template: `
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold mb-2" style="color: var(--text)">Kontakt</h1>
|
||||
<p class="mb-8" style="color: var(--secondary)">Wir beraten Sie persönlich – telefonisch, per E-Mail oder über das Kontaktformular.</p>
|
||||
<div class="grid lg:grid-cols-2 gap-8">
|
||||
<div class="space-y-6">
|
||||
<div class="hms-card p-6"><h2 class="text-lg font-semibold mb-4 flex items-center gap-2" style="color: var(--text)"><span style="color: var(--color-accent)">🏢</span> Büro Leipheim</h2><div class="space-y-1 text-sm" style="color: var(--text-muted)"><p>Grockelhofen 10<br>89340 Leipheim</p><a href="https://maps.google.com/?q=Grockelhofen+10+89340+Leipheim" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-2" style="color: var(--color-accent)">Route planen <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg></a></div></div>
|
||||
<div class="hms-card p-6"><h2 class="text-lg font-semibold mb-4 flex items-center gap-2" style="color: var(--text)"><span style="color: var(--color-accent)">📦</span> Mietlager Ellzee</h2><div class="space-y-1 text-sm" style="color: var(--text-muted)"><p>Zur Schönhalde 8<br>89352 Ellzee</p><a href="https://maps.google.com/?q=Zur+Schönhalde+8+89352+Ellzee" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-2" style="color: var(--color-accent)">Route planen <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg></a></div></div>
|
||||
<div class="hms-card p-6"><h2 class="text-lg font-semibold mb-4 flex items-center gap-2" style="color: var(--text)"><span style="color: var(--color-accent)">🕐</span> Öffnungszeiten</h2><div class="text-sm" style="color: var(--text-muted)"><div class="flex justify-between py-2 border-b" :style="{ borderColor: 'var(--border)' }"><span>Montag – Freitag</span><span class="font-medium" style="color: var(--text)">10:00 – 18:00</span></div><div class="flex justify-between py-2"><span>Samstag & Sonntag</span><span style="color: var(--secondary)">Geschlossen</span></div></div></div>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<div class="hms-card p-6 text-center"><div class="w-16 h-16 rounded-full mx-auto mb-3 flex items-center justify-center text-2xl" style="background: var(--surface); color: var(--secondary)">👤</div><h3 class="font-semibold" style="color: var(--text)">Leopold Hammerschmidt</h3><div class="text-xs mb-3" style="color: var(--secondary)">Geschäftsführung</div><a href="tel:+491726264796" class="block text-sm mb-1" style="color: var(--text-muted)">+49 (0) 172 6264796</a><a href="mailto:leopold.hammerschmidt@hms-licht-ton.de" class="block text-sm break-all" style="color: var(--text-muted)">leopold.hammerschmidt@hms-licht-ton.de</a></div>
|
||||
<div class="hms-card p-6 text-center"><div class="w-16 h-16 rounded-full mx-auto mb-3 flex items-center justify-center text-2xl" style="background: var(--surface); color: var(--secondary)">👤</div><h3 class="font-semibold" style="color: var(--text)">Andreas Mössle</h3><div class="text-xs mb-3" style="color: var(--secondary)">Geschäftsführung</div><a href="tel:+491739014604" class="block text-sm mb-1" style="color: var(--text-muted)">+49 (0) 173 / 9014604</a><a href="mailto:andreas.moessle@hms-licht-ton.de" class="block text-sm break-all" style="color: var(--text-muted)">andreas.moessle@hms-licht-ton.de</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="hms-card p-6 sm:p-8">
|
||||
<h2 class="text-xl font-semibold mb-6" style="color: var(--text)">Nachricht senden</h2>
|
||||
<div v-if="submitted" class="text-center py-8" role="status">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" :style="{ background: 'var(--color-success-bg)', color: 'var(--color-success)' }"><svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg></div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text)">Nachricht übermittelt</h3>
|
||||
<p class="text-sm mb-6" style="color: var(--secondary)">Vielen Dank für Ihre Anfrage. Wir melden uns innerhalb von 24 Stunden bei Ihnen.</p>
|
||||
<button @click="resetForm" class="hms-btn hms-btn-secondary">Neue Nachricht</button>
|
||||
</div>
|
||||
<form v-else @submit.prevent="submit" novalidate>
|
||||
<div class="space-y-4">
|
||||
<div><label for="name" class="block text-sm font-medium mb-1" style="color: var(--text-muted)">Name <span style="color: var(--color-error)" aria-label="Pflichtfeld">*</span></label><input id="name" v-model="form.name" type="text" :class="['hms-input', errors.name ? 'hms-input-error' : '']" placeholder="Ihr Name" :aria-invalid="!!errors.name" /><p v-if="errors.name" class="text-xs mt-1" style="color: var(--color-error)" role="alert">{{ errors.name }}</p></div>
|
||||
<div><label for="email" class="block text-sm font-medium mb-1" style="color: var(--text-muted)">E-Mail <span style="color: var(--color-error)" aria-label="Pflichtfeld">*</span></label><input id="email" v-model="form.email" type="email" :class="['hms-input', errors.email ? 'hms-input-error' : '']" placeholder="ihre@email.de" :aria-invalid="!!errors.email" /><p v-if="errors.email" class="text-xs mt-1" style="color: var(--color-error)" role="alert">{{ errors.email }}</p></div>
|
||||
<div><label for="phone" class="block text-sm font-medium mb-1" style="color: var(--text-muted)">Telefon</label><input id="phone" v-model="form.phone" type="tel" class="hms-input" placeholder="+49 ..." /></div>
|
||||
<div><label for="subject" class="block text-sm font-medium mb-1" style="color: var(--text-muted)">Anliegen</label><select id="subject" v-model="form.subject" class="hms-input"><option value="">Bitte wählen</option><option value="vermietung">Vermietungsanfrage</option><option value="verkauf">Verkaufsberatung</option><option value="personal">Personalanfrage</option><option value="installation">Installationsanfrage</option><option value="sonstiges">Sonstiges</option></select></div>
|
||||
<div><label for="message" class="block text-sm font-medium mb-1" style="color: var(--text-muted)">Nachricht <span style="color: var(--color-error)" aria-label="Pflichtfeld">*</span></label><textarea id="message" v-model="form.message" rows="5" :class="['hms-input', errors.message ? 'hms-input-error' : '']" placeholder="Beschreiben Sie Ihr Anliegen – Veranstaltungstyp, Ort, erwartete Besucherzahl, benötigtes Equipment..." :aria-invalid="!!errors.message"></textarea><p v-if="errors.message" class="text-xs mt-1" style="color: var(--color-error)" role="alert">{{ errors.message }}</p></div>
|
||||
<div><label class="flex items-start gap-3 cursor-pointer"><input type="checkbox" v-model="form.privacy" class="mt-1 w-5 h-5 rounded" :style="{ accentColor: 'var(--color-accent)' }" :aria-invalid="!!errors.privacy" /><span class="text-xs" style="color: var(--text-muted)">Ich habe die <a href="#/dsgvo" style="color: var(--color-accent)">Datenschutzerklärung</a> gelesen und stimme zu, dass meine Angaben zur Bearbeitung meiner Anfrage gespeichert werden. <span style="color: var(--color-error)">*</span></span></label><p v-if="errors.privacy" class="text-xs mt-1" style="color: var(--color-error)" role="alert">{{ errors.privacy }}</p></div>
|
||||
<button type="submit" :disabled="submitting" class="hms-btn hms-btn-primary w-full text-base py-3"><span v-if="submitting" class="hms-spinner" style="width:18px;height:18px;border-width:2px"></span><span v-else>Nachricht senden</span></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
// ===== MIETKATALOG PAGE =====
|
||||
const MietkatalogPage = defineComponent({
|
||||
name: 'MietkatalogPage',
|
||||
emits: ['navigate', 'add-to-cart'],
|
||||
setup(_, { emit }) {
|
||||
const loading = ref(true); const error = ref(false);
|
||||
const searchQuery = ref(''); const activeCategory = ref('alle'); const sortBy = ref('name');
|
||||
const categories = ['alle', 'Tontechnik', 'Lichttechnik', 'Rigging', 'Video', 'Strom', 'Zubehör'];
|
||||
const allEquipment = [
|
||||
{ id: 1, code: 'LT-001', name: 'L-Acoustics K2', category: 'Tontechnik', description: 'High-Performance Line-Array Element mit variabler Krümmung für große Open-Air-Produktionen und Hallenbeschallung', brand: 'L-Acoustics', specs: { weight: '56 kg', power: '1440 W', freq_range: '35 Hz - 20 kHz' }, image: '' },
|
||||
{ id: 2, code: 'LT-002', name: 'd&b audiotechnik KSL8', category: 'Tontechnik', description: 'Bi-amplified Column Array Loudspeaker, 80° horizontal, für mittlere bis große Events', brand: 'd&b audiotechnik', specs: { weight: '32 kg', power: '1200 W', freq_range: '55 Hz - 18 kHz' }, image: '' },
|
||||
{ id: 3, code: 'LT-003', name: 'Shure SM58', category: 'Tontechnik', description: 'Dynamisches Gesangsmikrofon, Kardioid-Richtcharakteristik, Industrie-Standard seit Jahrzehnten', brand: 'Shure', specs: { weight: '330 g', type: 'dynamisch', polar: 'Kardioid' }, image: '' },
|
||||
{ id: 4, code: 'LT-004', name: 'Allen & Heath dLive S5000', category: 'Tontechnik', description: 'Digitales Mischpult mit 96 Input Fadern, 64 Mix Buses, Dante-kompatibel für große Live-Produktionen', brand: 'Allen & Heath', specs: { channels: '96', buses: '64', fx: '16 Effekt-Engines' }, image: '' },
|
||||
{ id: 5, code: 'LL-001', name: 'Robe Pointe', category: 'Lichttechnik', description: '230W Moving Beam mit 5°-20° Zoom, 16-bit Dimming, vielseitig für Beam- und Wash-Anwendungen', brand: 'Robe', specs: { power: '230 W', source: 'MSD 230', zoom: '5°-20°' }, image: '' },
|
||||
{ id: 6, code: 'LL-002', name: 'Mac Aura PXL', category: 'Lichttechnik', description: 'RGBW Wash-Light mit 18 pixel-mappable Zellen, Zoom 4°-60°, Art-Net steuerbar', brand: 'Martin', specs: { power: '700 W', cells: '18', zoom: '4°-60°' }, image: '' },
|
||||
{ id: 7, code: 'LL-003', name: 'Showtec Phantom 140', category: 'Lichttechnik', description: '140W LED Spot Moving Head, 9 rotierende + 9 feste Gobos, 8-fach Prisma', brand: 'Showtec', specs: { power: '140 W', gobos: '9 rotierend + 9 fest', zoom: '12°-18°' }, image: '' },
|
||||
{ id: 8, code: 'LL-004', name: 'Astera Titan Tube', category: 'Lichttechnik', description: 'Pixel Tube 1m, batteriebetrieben mit 20 Std. Laufzeit, DMX/CRMX-Steuerung, RGBW', brand: 'Astera', specs: { length: '1015 mm', battery: '20 Std', cells: '16 Pixel' }, image: '' },
|
||||
{ id: 9, code: 'RG-001', name: 'Tomcat Truss 290', category: 'Rigging', description: 'Aluminium Traverse 4-Punkt, 290mm Profil, 2m Länge, belastbar bis 450kg freitragend', brand: 'Tomcat', specs: { profile: '290x290 mm', length: '2 m', load: '450 kg' }, image: '' },
|
||||
{ id: 10, code: 'RG-002', name: 'Chain Motor 1T', category: 'Rigging', description: 'Bühnen-Motor 1000kg, D8+ Plus geprüft, inklusive Steuerkabel und Lasthaken', brand: 'Verlinde', specs: { capacity: '1000 kg', speed: '4-8 m/min', control: 'Direct Control' }, image: '' },
|
||||
{ id: 11, code: 'VD-001', name: 'LED Video Wall P3.9', category: 'Video', description: 'Indoor LED Panel P3.9, 500x500mm, 3840Hz Refresh Rate, 1500 nits für Bühnenhintergründe', brand: 'Absen', specs: { pixel_pitch: '3.9 mm', size: '500x500 mm', refresh: '3840 Hz' }, image: '' },
|
||||
{ id: 12, code: 'VD-002', name: 'Blackmagic ATEM Mini Extreme', category: 'Video', description: '8-Input HDMI Switcher mit ISO Recording, Multiview und Direct Streaming', brand: 'Blackmagic', specs: { inputs: '8x HDMI', outputs: '2x HDMI', recording: 'ISO Recording' }, image: '' },
|
||||
{ id: 13, code: 'ST-001', name: 'WAGO 24V 40A Netzteilkasten', category: 'Strom', description: '24V DC 40A Netzteil in Flightcase, 4x Schuko Ausgang, für LED- und Steuerungstechnik', brand: 'WAGO', specs: { output: '24V DC 40A', inputs: '4x Schuko', protection: 'IP54' }, image: '' },
|
||||
{ id: 14, code: 'ST-002', name: 'Duraplex 32A Stromverteiler', category: 'Strom', description: '32A 5-polig auf 3x 16A + 3x Schuko, mit FI-Schutzschalter, professionelle Bühnenstromversorgung', brand: 'Duraplex', specs: { input: '32A 5-pol CEE', outputs: '3x 16A + 3x Schuko', protection: 'FI/A' }, image: '' },
|
||||
{ id: 15, code: 'ZB-001', name: 'Flightcase 19" 12HE', category: 'Zubehör', description: '19" Rack Flightcase 12HE, Front- und Rücktür, 4x 100mm Rollen, Birkenholz mit Alu-Kanten', brand: 'Thon', specs: { rack_units: '12 HE', depth: '600 mm', wheels: '4x 100mm' }, image: '' },
|
||||
{ id: 16, code: 'ZB-002', name: 'XLR Kabel 20m', category: 'Zubehör', description: 'XLR3 male/female Mikrofonkabel, 20m, Neutrik Stecker, doppelt geschirmt', brand: 'Tasker', specs: { length: '20 m', connectors: 'XLR3 M/F', shielding: 'double' }, image: '' },
|
||||
{ id: 17, code: 'ZB-003', name: 'DJ-Table Pro', category: 'Zubehör', description: 'Mobile DJ-Tisch mit Laptop-Ständer und integrierter LED-Beleuchtung, transportiert im Flightcase', brand: 'Stage Traps', specs: { width: '120 cm', height: '95 cm', features: 'LED, Laptop-Ständer' }, image: '' },
|
||||
{ id: 18, code: 'LT-005', name: 'Sennheiser EW-DX 835', category: 'Tontechnik', description: 'Digitales Funkmikrofon-Set, Handheld + Empfänger, 100m Reichweite, 12 Std. Akkulaufzeit', brand: 'Sennheiser', specs: { type: 'Digital Wireless', range: '100 m', battery: '12 Std' }, image: '' }
|
||||
];
|
||||
const filteredEquipment = computed(() => {
|
||||
let items = allEquipment;
|
||||
if (activeCategory.value !== 'alle') items = items.filter(i => i.category === activeCategory.value);
|
||||
if (searchQuery.value.trim()) { const q = searchQuery.value.toLowerCase(); items = items.filter(i => i.name.toLowerCase().includes(q) || i.code.toLowerCase().includes(q) || i.brand.toLowerCase().includes(q)); }
|
||||
if (sortBy.value === 'name') items = [...items].sort((a,b) => a.name.localeCompare(b.name));
|
||||
else if (sortBy.value === 'brand') items = [...items].sort((a,b) => a.brand.localeCompare(b.brand));
|
||||
else if (sortBy.value === 'code') items = [...items].sort((a,b) => a.code.localeCompare(b.code));
|
||||
return items;
|
||||
});
|
||||
onMounted(() => { setTimeout(() => { loading.value = false; }, 1000); });
|
||||
function navigate(route) { window.location.hash = route; window.scrollTo(0, 0); }
|
||||
function navigateToDetail(item) { window.location.hash = '#/mietkatalog/' + item.id; window.scrollTo(0, 0); }
|
||||
function retry() { loading.value = true; error.value = false; setTimeout(() => { loading.value = false; }, 800); }
|
||||
return { loading, error, searchQuery, activeCategory, sortBy, categories, filteredEquipment, navigate, navigateToDetail, retry };
|
||||
},
|
||||
template: `
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||
<div><h1 class="text-3xl sm:text-4xl font-bold mb-2" style="color: var(--text)">Mietkatalog</h1><p style="color: var(--secondary)">Durchsuchen Sie unser Equipment-Sortiment und stellen Sie Ihre Mietanfrage zusammen.</p></div>
|
||||
<button @click="navigate('#/warenkorb')" class="hms-btn hms-btn-secondary text-sm self-start sm:self-auto">🛒 Warenkorb ansehen</button>
|
||||
</div>
|
||||
<div class="hms-card p-4 mb-6">
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<div class="relative flex-1"><svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5" style="color: var(--secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg><input v-model="searchQuery" type="search" class="hms-input pl-10" placeholder="Gerät, Marke oder Artikelnummer suchen..." aria-label="Equipment suchen" /></div>
|
||||
<select v-model="sortBy" class="hms-input lg:w-48" aria-label="Sortieren nach"><option value="name">Sortieren: Name (A-Z)</option><option value="brand">Sortieren: Marke (A-Z)</option><option value="code">Sortieren: Artikelnummer</option></select>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mt-4" role="tablist" aria-label="Kategorie-Filter"><button v-for="cat in categories" :key="cat" @click="activeCategory = cat" :class="['hms-chip', activeCategory === cat ? 'active' : '']" role="tab" :aria-selected="activeCategory === cat">{{ cat === 'alle' ? 'Alle Kategorien' : cat }}</button></div>
|
||||
</div>
|
||||
<div v-if="!loading && !error" class="text-sm mb-4" style="color: var(--secondary)" aria-live="polite">{{ filteredEquipment.length }} {{ filteredEquipment.length === 1 ? 'Gerät' : 'Geräte' }} gefunden</div>
|
||||
<loading-skeleton v-if="loading" :count="6" />
|
||||
<error-state v-else-if="error" title="Katalog nicht erreichbar" message="Der Equipment-Katalog ist aktuell nicht verfügbar." @retry="retry" />
|
||||
<empty-state v-else-if="filteredEquipment.length === 0" icon="🔍" title="Keine Geräte gefunden" message="Für Ihre Suchanfrage wurden keine Geräte gefunden." action-label="Filter zurücksetzen" @action="searchQuery=''; activeCategory='alle'" />
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"><equipment-card v-for="item in filteredEquipment" :key="item.id" :item="item" @click="navigateToDetail" @add-to-cart="$emit('add-to-cart', $event)" /></div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
// ===== EQUIPMENT DETAIL PAGE =====
|
||||
const EquipmentDetailPage = defineComponent({
|
||||
name: 'EquipmentDetailPage',
|
||||
props: { id: [String, Number], cart: Array },
|
||||
emits: ['add-to-cart', 'navigate'],
|
||||
setup(props, { emit }) {
|
||||
const loading = ref(true); const error = ref(false); const quantity = ref(1);
|
||||
const rentalStart = ref(''); const rentalEnd = ref(''); const item = ref(null);
|
||||
const equipmentData = [
|
||||
{ id: 1, code: 'LT-001', name: 'L-Acoustics K2', category: 'Tontechnik', description: 'Das L-Acoustics K2 ist das Flaggschiff der Line-Array-Serie und eignet sich für große Open-Air-Veranstaltungen und Hallenbeschallung.', brand: 'L-Acoustics', specs: { weight: '56 kg', power: '1440 W', freq_range: '35 Hz - 20 kHz', max_spl: '142 dB', coverage: '10°-110° variable' }, image: '', related: [2, 4, 3] },
|
||||
{ id: 2, code: 'LT-002', name: 'd&b audiotechnik KSL8', category: 'Tontechnik', description: 'Bi-amplified Column Array Loudspeaker mit 80° horizontaler Abstrahlung.', brand: 'd&b audiotechnik', specs: { weight: '32 kg', power: '1200 W', freq_range: '55 Hz - 18 kHz', max_spl: '138 dB', coverage: '80° horizontal' }, image: '', related: [1, 4, 18] },
|
||||
{ id: 3, code: 'LT-003', name: 'Shure SM58', category: 'Tontechnik', description: 'Das weltweit meistverkaufte dynamische Gesangsmikrofon.', brand: 'Shure', specs: { weight: '330 g', type: 'dynamisch', polar: 'Kardioid', freq_range: '50 Hz - 15 kHz', connector: 'XLR3' }, image: '', related: [4, 18, 1] },
|
||||
{ id: 4, code: 'LT-004', name: 'Allen & Heath dLive S5000', category: 'Tontechnik', description: 'Professionelles digitales Mischpult mit 96 Input Fadern und 64 Mix Buses.', brand: 'Allen & Heath', specs: { channels: '96', buses: '64', fx: '16 Effekt-Engines', screens: '2x 15" Touch', i_o: '128x128 Dante' }, image: '', related: [1, 2, 3] },
|
||||
{ id: 5, code: 'LL-001', name: 'Robe Pointe', category: 'Lichttechnik', description: '230W Moving Beam mit 5°-20° Zoom und 16-bit Dimming.', brand: 'Robe', specs: { power: '230 W', source: 'MSD 230', zoom: '5°-20°', dimming: '16-bit', pan: '540°', tilt: '270°' }, image: '', related: [6, 7, 8] },
|
||||
{ id: 6, code: 'LL-002', name: 'Mac Aura PXL', category: 'Lichttechnik', description: 'RGBW Wash-Light mit 18 einzelnen pixel-mappable Zellen.', brand: 'Martin', specs: { power: '700 W', cells: '18', zoom: '4°-60°', control: 'DMX 512, Art-Net', color: 'RGBW' }, image: '', related: [5, 7, 8] },
|
||||
{ id: 7, code: 'LL-003', name: 'Showtec Phantom 140', category: 'Lichttechnik', description: '140W LED Spot Moving Head mit 9 rotierenden und 9 festen Gobos.', brand: 'Showtec', specs: { power: '140 W', gobos: '9 rotierend + 9 fest', zoom: '12°-18°', color: '7+1 Farbrad', prism: '8-fach rotierend' }, image: '', related: [5, 6, 8] },
|
||||
{ id: 8, code: 'LL-004', name: 'Astera Titan Tube', category: 'Lichttechnik', description: 'Pixel Tube 1m Länge, batteriebetrieben mit 20 Std. Laufzeit.', brand: 'Astera', specs: { length: '1015 mm', battery: '20 Std', cells: '16 Pixel', control: 'DMX, CRMX, App', color: 'RGBW' }, image: '', related: [5, 6, 7] },
|
||||
{ id: 9, code: 'RG-001', name: 'Tomcat Truss 290', category: 'Rigging', description: 'Aluminium Traverse 4-Punkt, 290mm Profil, 2m Länge.', brand: 'Tomcat', specs: { profile: '290x290 mm', length: '2 m', load: '450 kg', material: 'Aluminium EN AW-6082' }, image: '', related: [10, 14, 15] },
|
||||
{ id: 10, code: 'RG-002', name: 'Chain Motor 1T', category: 'Rigging', description: 'Bühnen-Motor 1000kg Tragkraft, D8+ Plus geprüft.', brand: 'Verlinde', specs: { capacity: '1000 kg', speed: '4-8 m/min', control: 'Direct Control', certification: 'D8+ Plus' }, image: '', related: [9, 14, 13] },
|
||||
{ id: 11, code: 'VD-001', name: 'LED Video Wall P3.9', category: 'Video', description: 'Indoor LED Panel P3.9, 500x500mm, 3840Hz Refresh Rate.', brand: 'Absen', specs: { pixel_pitch: '3.9 mm', size: '500x500 mm', refresh: '3840 Hz', brightness: '1500 nits', ip: 'IP30 Indoor' }, image: '', related: [12, 5, 6] },
|
||||
{ id: 12, code: 'VD-002', name: 'Blackmagic ATEM Mini Extreme', category: 'Video', description: '8-Input HDMI Switcher mit ISO Recording und Multiview.', brand: 'Blackmagic', specs: { inputs: '8x HDMI', outputs: '2x HDMI', recording: 'ISO Recording', streaming: 'Direct Streaming', audio: '2x 3.5mm' }, image: '', related: [11, 5, 7] },
|
||||
{ id: 13, code: 'ST-001', name: 'WAGO 24V 40A Netzteilkasten', category: 'Strom', description: '24V DC 40A Netzteil in Flightcase mit 4x Schuko Ausgang.', brand: 'WAGO', specs: { output: '24V DC 40A', inputs: '4x Schuko', protection: 'IP54', cooling: 'Forced Air' }, image: '', related: [14, 10, 9] },
|
||||
{ id: 14, code: 'ST-002', name: 'Duraplex 32A Stromverteiler', category: 'Strom', description: '32A 5-polig auf 3x 16A + 3x Schuko mit FI-Schutzschalter.', brand: 'Duraplex', specs: { input: '32A 5-pol CEE', outputs: '3x 16A + 3x Schuko', protection: 'FI/A', cable: 'H07BQ-F 5G6' }, image: '', related: [13, 10, 9] },
|
||||
{ id: 15, code: 'ZB-001', name: 'Flightcase 19" 12HE', category: 'Zubehör', description: '19" Rack Flightcase 12HE mit Front- und Rücktür.', brand: 'Thon', specs: { rack_units: '12 HE', depth: '600 mm', wheels: '4x 100mm', material: 'Birkenholz + Alu-Kanten' }, image: '', related: [16, 4, 15] },
|
||||
{ id: 16, code: 'ZB-002', name: 'XLR Kabel 20m', category: 'Zubehör', description: 'XLR3 male/female Mikrofonkabel, 20m, Neutrik Stecker.', brand: 'Tasker', specs: { length: '20 m', connectors: 'XLR3 M/F', shielding: 'double', cable: 'Tasker C118' }, image: '', related: [3, 18, 4] },
|
||||
{ id: 17, code: 'ZB-003', name: 'DJ-Table Pro', category: 'Zubehör', description: 'Mobile DJ-Tisch mit Laptop-Ständer und LED-Beleuchtung.', brand: 'Stage Traps', specs: { width: '120 cm', height: '95 cm', features: 'LED, Laptop-Ständer', weight: '22 kg' }, image: '', related: [12, 5, 15] },
|
||||
{ id: 18, code: 'LT-005', name: 'Sennheiser EW-DX 835', category: 'Tontechnik', description: 'Digitales Funkmikrofon-Set mit Handheld und Empfänger.', brand: 'Sennheiser', specs: { type: 'Digital Wireless', range: '100 m', battery: '12 Std', freq_range: '563-608 MHz', channels: 'gleichzeitig 95' }, image: '', related: [3, 4, 16] }
|
||||
];
|
||||
const relatedItems = computed(() => { if (!item.value || !item.value.related) return []; return item.value.related.map(id => equipmentData.find(e => e.id === id)).filter(Boolean); });
|
||||
onMounted(() => { setTimeout(() => { const found = equipmentData.find(e => e.id === Number(props.id)); if (found) { item.value = found; } else { error.value = true; } loading.value = false; }, 800); });
|
||||
function navigate(route) { window.location.hash = route; window.scrollTo(0, 0); }
|
||||
function addToCart() { emit('add-to-cart', { ...item.value, quantity: quantity.value, rentalStart: rentalStart.value, rentalEnd: rentalEnd.value }); navigate('#/warenkorb'); }
|
||||
function addToCartSimple(e) { emit('add-to-cart', e); }
|
||||
return { loading, error, item, quantity, rentalStart, rentalEnd, relatedItems, navigate, addToCart, addToCartSimple };
|
||||
},
|
||||
template: `
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<nav class="text-sm mb-6" aria-label="Breadcrumb"><a href="#/mietkatalog" @click.prevent="navigate('#/mietkatalog')" style="color: var(--secondary)">← Zurück zum Katalog</a></nav>
|
||||
<div v-if="loading" class="grid lg:grid-cols-2 gap-8" aria-live="polite" aria-busy="true"><div class="hms-skeleton aspect-square rounded-lg"></div><div class="space-y-4"><div class="hms-skeleton h-8 w-3/4"></div><div class="hms-skeleton h-4 w-1/4"></div><div class="hms-skeleton h-24 w-full"></div><div class="hms-skeleton h-32 w-full"></div><div class="hms-skeleton h-12 w-full" style="border-radius:var(--radius-md)"></div></div></div>
|
||||
<error-state v-else-if="error" title="Gerät nicht gefunden" message="Das angeforderte Gerät konnte nicht gefunden werden." @retry="navigate('#/mietkatalog')" />
|
||||
<div v-else-if="item">
|
||||
<div class="grid lg:grid-cols-2 gap-8 lg:gap-12">
|
||||
<div class="aspect-square rounded-lg flex items-center justify-center relative overflow-hidden border" :style="{ background: 'var(--surface)', borderColor: 'var(--border)' }">
|
||||
<img v-if="item.image" :src="item.image" :alt="item.name" class="absolute inset-0 w-full h-full object-cover" />
|
||||
<div v-else class="text-center"><div class="text-8xl mb-2" style="color: var(--secondary)">📦</div><div class="text-sm" style="color: var(--secondary)">Kein Bild verfügbar</div></div>
|
||||
<span class="hms-badge hms-badge-primary absolute top-4 left-4 text-sm">{{ item.category }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm mb-1" style="color: var(--secondary)">Artikelnummer: {{ item.code }}</div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold mb-2" style="color: var(--text)">{{ item.name }}</h1>
|
||||
<div class="text-sm mb-4" style="color: var(--text-muted)">Marke: <span class="font-medium" style="color: var(--text)">{{ item.brand }}</span></div>
|
||||
<p class="leading-relaxed mb-6" style="color: var(--text-muted)">{{ item.description }}</p>
|
||||
<div class="hms-card p-4 mb-6"><h2 class="text-sm font-semibold mb-3" style="color: var(--text)">Technische Daten</h2><dl class="divide-y" :style="{ borderColor: 'var(--border)' }"><div v-for="(value, key) in item.specs" :key="key" class="flex justify-between py-2 text-sm" :style="{ borderColor: 'var(--border)' }"><dt class="capitalize" style="color: var(--secondary)">{{ key.replace(/_/g, ' ') }}</dt><dd class="font-medium text-right" style="color: var(--text)">{{ value }}</dd></div></dl></div>
|
||||
<div class="hms-card p-6">
|
||||
<h2 class="text-sm font-semibold mb-4" style="color: var(--text)">Mietanfrage</h2>
|
||||
<div class="grid grid-cols-2 gap-3 mb-4"><div><label for="rental-start" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Mietbeginn</label><input id="rental-start" v-model="rentalStart" type="date" class="hms-input text-sm" /></div><div><label for="rental-end" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Mietende</label><input id="rental-end" v-model="rentalEnd" type="date" class="hms-input text-sm" /></div></div>
|
||||
<div class="mb-4"><label for="quantity" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Anzahl</label><div class="flex items-center gap-3"><button @click="quantity = Math.max(1, quantity - 1)" class="hms-btn hms-btn-secondary px-3 py-2" aria-label="Anzahl verringern">−</button><input id="quantity" v-model.number="quantity" type="number" min="1" class="hms-input text-center w-20" aria-label="Anzahl" /><button @click="quantity = quantity + 1" class="hms-btn hms-btn-secondary px-3 py-2" aria-label="Anzahl erhöhen">+</button></div></div>
|
||||
<button @click="addToCart" class="hms-btn hms-btn-primary w-full py-3 text-base"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/></svg>Zur Mietanfrage hinzufügen</button>
|
||||
<p class="text-xs mt-3 text-center" style="color: var(--secondary)">Preise auf Anfrage – unverbindliche Mietanfrage</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section v-if="relatedItems.length" class="mt-16" aria-labelledby="related-title"><h2 id="related-title" class="text-xl font-semibold mb-6" style="color: var(--text)">Ähnliche Geräte</h2><div class="grid grid-cols-1 sm:grid-cols-3 gap-6"><equipment-card v-for="ri in relatedItems" :key="ri.id" :item="ri" @click="navigate('#/mietkatalog/' + ri.id)" @add-to-cart="addToCartSimple" /></div></section>
|
||||
</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
// ===== WARENKORB PAGE =====
|
||||
const WarenkorbPage = defineComponent({
|
||||
name: 'WarenkorbPage',
|
||||
props: { cart: Array },
|
||||
emits: ['remove-item', 'update-quantity', 'navigate', 'clear-cart'],
|
||||
setup(props, { emit }) {
|
||||
const submitting = ref(false); const submitted = ref(false);
|
||||
const form = reactive({ name: '', email: '', phone: '', event_date: '', event_location: '', message: '' });
|
||||
const errors = reactive({});
|
||||
function removeItem(index) { emit('remove-item', index); }
|
||||
function updateQty(index, delta) { emit('update-quantity', { index, delta }); }
|
||||
function navigate(route) { window.location.hash = route; window.scrollTo(0, 0); }
|
||||
function validate() {
|
||||
const e = {};
|
||||
if (!form.name.trim()) e.name = 'Name ist erforderlich';
|
||||
if (!form.email.trim()) e.email = 'E-Mail ist erforderlich';
|
||||
else if (!/^\S+@\S+\.\S+$/.test(form.email)) e.email = 'Ungültige E-Mail';
|
||||
if (!form.event_date) e.event_date = 'Veranstaltungsdatum erforderlich';
|
||||
if (!form.event_location.trim()) e.event_location = 'Veranstaltungsort erforderlich';
|
||||
Object.keys(errors).forEach(k => delete errors[k]); Object.assign(errors, e);
|
||||
return Object.keys(e).length === 0;
|
||||
}
|
||||
function submitRequest() { if (!validate()) return; submitting.value = true; setTimeout(() => { submitting.value = false; submitted.value = true; emit('clear-cart'); }, 1800); }
|
||||
function resetForm() { Object.assign(form, { name: '', email: '', phone: '', event_date: '', event_location: '', message: '' }); submitted.value = false; }
|
||||
return { submitting, submitted, form, errors, removeItem, updateQty, navigate, validate, submitRequest, resetForm };
|
||||
},
|
||||
template: `
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold mb-2" style="color: var(--text)">Mietanfrage</h1>
|
||||
<p class="mb-8" style="color: var(--secondary)">Überprüfen Sie Ihre Geräteauswahl und senden Sie die Anfrage ab.</p>
|
||||
<div v-if="submitted" class="hms-card p-8 text-center" role="status">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" :style="{ background: 'var(--color-success-bg)', color: 'var(--color-success)' }"><svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg></div>
|
||||
<h2 class="text-xl font-semibold mb-2" style="color: var(--text)">Mietanfrage übermittelt</h2>
|
||||
<p class="mb-6" style="color: var(--secondary)">Vielen Dank! Wir melden uns innerhalb von 24 Stunden mit einem unverbindlichen Angebot.</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center"><button @click="navigate('#/mietkatalog')" class="hms-btn hms-btn-secondary">Weiter stöbern</button><button @click="navigate('#/')" class="hms-btn hms-btn-primary">Zur Startseite</button></div>
|
||||
</div>
|
||||
<empty-state v-else-if="cart.length === 0" icon="🛒" title="Warenkorb ist leer" message="Fügen Sie Geräte aus dem Mietkatalog hinzu, um eine Mietanfrage zu stellen." action-label="Zum Mietkatalog" @action="navigate('#/mietkatalog')" />
|
||||
<div v-else class="grid lg:grid-cols-3 gap-8">
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<div v-for="(item, index) in cart" :key="index" class="hms-card p-4 sm:p-6">
|
||||
<div class="flex gap-4">
|
||||
<div class="w-20 h-20 rounded-lg flex items-center justify-center flex-shrink-0 text-2xl" style="background: var(--surface); color: var(--secondary)">📦</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-2"><div><h3 class="font-semibold text-sm sm:text-base" style="color: var(--text)">{{ item.name }}</h3><div class="text-xs mt-0.5" style="color: var(--secondary)">{{ item.code }} · {{ item.brand }}</div></div>
|
||||
<button @click="removeItem(index)" class="p-1" style="color: var(--secondary)" :aria-label="item.name + ' entfernen'"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg></button>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 mt-3"><div class="flex items-center gap-2"><button @click="updateQty(index, -1)" class="w-7 h-7 rounded border flex items-center justify-center" style="border-color: var(--border-strong); color: var(--text-muted)" aria-label="Anzahl verringern">−</button><span class="w-8 text-center text-sm font-medium" style="color: var(--text)">{{ item.quantity }}</span><button @click="updateQty(index, 1)" class="w-7 h-7 rounded border flex items-center justify-center" style="border-color: var(--border-strong); color: var(--text-muted)" aria-label="Anzahl erhöhen">+</button></div><div v-if="item.rentalStart" class="text-xs" style="color: var(--secondary)">📅 {{ item.rentalStart }} <span v-if="item.rentalEnd">bis {{ item.rentalEnd }}</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="$emit('clear-cart')" class="text-sm hover:text-[var(--color-error)]" style="color: var(--secondary)">Warenkorb leeren</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="hms-card p-6 sticky top-20">
|
||||
<h2 class="text-lg font-semibold mb-4" style="color: var(--text)">Anfrage senden</h2>
|
||||
<div class="text-sm mb-4 pb-4 border-b" :style="{ borderColor: 'var(--border)', color: 'var(--secondary)' }"><div class="flex justify-between mb-1"><span>Geräte gesamt:</span><span class="font-medium" style="color: var(--text)">{{ cart.reduce((s,i)=>s+i.quantity,0) }}</span></div><div class="flex justify-between"><span>Positionen:</span><span class="font-medium" style="color: var(--text)">{{ cart.length }}</span></div></div>
|
||||
<form @submit.prevent="submitRequest" novalidate>
|
||||
<div class="space-y-3">
|
||||
<div><label for="req-name" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Name <span style="color: var(--color-error)">*</span></label><input id="req-name" v-model="form.name" type="text" :class="['hms-input text-sm', errors.name ? 'hms-input-error' : '']" placeholder="Ihr Name" /><p v-if="errors.name" class="text-xs mt-1" style="color: var(--color-error)">{{ errors.name }}</p></div>
|
||||
<div><label for="req-email" class="block text-xs font-medium mb-1" style="color: var(--secondary)">E-Mail <span style="color: var(--color-error)">*</span></label><input id="req-email" v-model="form.email" type="email" :class="['hms-input text-sm', errors.email ? 'hms-input-error' : '']" placeholder="ihre@email.de" /><p v-if="errors.email" class="text-xs mt-1" style="color: var(--color-error)">{{ errors.email }}</p></div>
|
||||
<div><label for="req-phone" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Telefon</label><input id="req-phone" v-model="form.phone" type="tel" class="hms-input text-sm" placeholder="+49 ..." /></div>
|
||||
<div><label for="req-date" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Veranstaltungsdatum <span style="color: var(--color-error)">*</span></label><input id="req-date" v-model="form.event_date" type="date" :class="['hms-input text-sm', errors.event_date ? 'hms-input-error' : '']" /><p v-if="errors.event_date" class="text-xs mt-1" style="color: var(--color-error)">{{ errors.event_date }}</p></div>
|
||||
<div><label for="req-location" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Veranstaltungsort <span style="color: var(--color-error)">*</span></label><input id="req-location" v-model="form.event_location" type="text" :class="['hms-input text-sm', errors.event_location ? 'hms-input-error' : '']" placeholder="Ort / Location" /><p v-if="errors.event_location" class="text-xs mt-1" style="color: var(--color-error)">{{ errors.event_location }}</p></div>
|
||||
<div><label for="req-message" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Anmerkung</label><textarea id="req-message" v-model="form.message" rows="3" class="hms-input text-sm" placeholder="Zusätzliche Informationen..."></textarea></div>
|
||||
<button type="submit" :disabled="submitting" class="hms-btn hms-btn-primary w-full py-3"><span v-if="submitting" class="hms-spinner" style="width:18px;height:18px;border-width:2px"></span><span v-else>Anfrage absenden</span></button>
|
||||
<p class="text-xs text-center" style="color: var(--secondary)">Unverbindlich – Angebot innerhalb 24 Std.</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
// ===== ADMIN PAGE =====
|
||||
const AdminPage = defineComponent({
|
||||
name: 'AdminPage',
|
||||
setup() {
|
||||
const username = ref(''); const password = ref(''); const error = ref(''); const loading = ref(false); const loggedIn = ref(false);
|
||||
function login() { error.value = ''; if (!username.value || !password.value) { error.value = 'Bitte Benutzername und Passwort eingeben'; return; } loading.value = true; setTimeout(() => { loading.value = false; loggedIn.value = true; }, 1200); }
|
||||
return { username, password, error, loading, loggedIn, login };
|
||||
},
|
||||
template: `
|
||||
<div class="max-w-md mx-auto px-4 py-16"><div class="hms-card p-8"><div class="text-center mb-6"><hms-logo :size="48" /><h1 class="text-xl font-bold mt-4" style="color: var(--text)">Admin-Login</h1><p class="text-sm mt-1" style="color: var(--secondary)">Equipment-Sync Verwaltungsbereich</p></div>
|
||||
<div v-if="loggedIn" class="text-center py-8" role="status"><div class="inline-flex items-center justify-center w-12 h-12 rounded-full mb-3" :style="{ background: 'var(--color-success-bg)', color: 'var(--color-success)' }"><svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg></div><p class="font-medium" style="color: var(--text)">Eingeloggt</p><p class="text-xs mt-1" style="color: var(--secondary)">(Prototyp – keine echte Session)</p></div>
|
||||
<form v-else @submit.prevent="login" novalidate><div class="space-y-4"><div><label for="admin-user" class="block text-sm font-medium mb-1" style="color: var(--text-muted)">Benutzername</label><input id="admin-user" v-model="username" type="text" class="hms-input" placeholder="admin" :aria-invalid="!!error" /></div><div><label for="admin-pass" class="block text-sm font-medium mb-1" style="color: var(--text-muted)">Passwort</label><input id="admin-pass" v-model="password" type="password" class="hms-input" placeholder="••••••••" :aria-invalid="!!error" /></div><p v-if="error" class="text-xs" style="color: var(--color-error)" role="alert">{{ error }}</p><button type="submit" :disabled="loading" class="hms-btn hms-btn-primary w-full py-3"><span v-if="loading" class="hms-spinner" style="width:18px;height:18px;border-width:2px"></span><span v-else>Einloggen</span></button></div></form></div></div>`
|
||||
});
|
||||
|
||||
// ===== 404 =====
|
||||
const NotFoundPage = defineComponent({
|
||||
name: 'NotFoundPage',
|
||||
template: `<div class="max-w-md mx-auto px-4 py-24 text-center"><div class="text-8xl font-bold mb-4" style="color: var(--secondary)">404</div><h1 class="text-2xl font-semibold mb-2" style="color: var(--text)">Seite nicht gefunden</h1><p class="mb-8" style="color: var(--secondary)">Die angeforderte Seite existiert nicht.</p><a href="#/" class="hms-btn hms-btn-primary">Zur Startseite</a></div>`
|
||||
});
|
||||
|
||||
// ===== MAIN APP =====
|
||||
const App = defineComponent({
|
||||
name: 'App',
|
||||
setup() {
|
||||
const currentRoute = ref(window.location.hash || '#/');
|
||||
const cart = reactive([]);
|
||||
function addToCart(item) { const existing = cart.find(i => i.id === item.id); if (existing) { existing.quantity += item.quantity || 1; } else { cart.push({ ...item, quantity: item.quantity || 1 }); } }
|
||||
function removeItem(index) { cart.splice(index, 1); }
|
||||
function updateQuantity({ index, delta }) { const item = cart[index]; if (item) { item.quantity = Math.max(1, item.quantity + delta); } }
|
||||
function clearCart() { cart.splice(0, cart.length); }
|
||||
const routeParts = computed(() => { const hash = currentRoute.value.replace(/^#/, '') || '/'; return hash.split('/').filter(Boolean); });
|
||||
const currentView = computed(() => { const parts = routeParts.value; if (parts.length === 0) return 'home'; if (parts[0] === 'referenzen') return 'referenzen'; if (parts[0] === 'kontakt') return 'kontakt'; if (parts[0] === 'mietkatalog') return parts.length > 1 ? 'equipment-detail' : 'mietkatalog'; if (parts[0] === 'warenkorb') return 'warenkorb'; if (parts[0] === 'admin') return 'admin'; return '404'; });
|
||||
const detailId = computed(() => { const parts = routeParts.value; if (parts[0] === 'mietkatalog' && parts.length > 1) return parts[1]; return null; });
|
||||
window.addEventListener('hashchange', () => { currentRoute.value = window.location.hash || '#/'; window.scrollTo(0, 0); });
|
||||
return { currentRoute, currentView, detailId, cart, addToCart, removeItem, updateQuantity, clearCart };
|
||||
},
|
||||
template: `
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a>
|
||||
<app-header />
|
||||
<main id="main-content" class="flex-1" role="main">
|
||||
<transition name="fade" mode="out-in">
|
||||
<home-page v-if="currentView === 'home'" @navigate @add-to-cart="addToCart" key="home" />
|
||||
<referenzen-page v-else-if="currentView === 'referenzen'" key="referenzen" />
|
||||
<kontakt-page v-else-if="currentView === 'kontakt'" key="kontakt" />
|
||||
<mietkatalog-page v-else-if="currentView === 'mietkatalog'" @navigate @add-to-cart="addToCart" key="mietkatalog" />
|
||||
<equipment-detail-page v-else-if="currentView === 'equipment-detail'" :id="detailId" :cart="cart" @navigate @add-to-cart="addToCart" :key="'detail-' + detailId" />
|
||||
<warenkorb-page v-else-if="currentView === 'warenkorb'" :cart="cart" @remove-item="removeItem" @update-quantity="updateQuantity" @navigate @clear-cart="clearCart" key="warenkorb" />
|
||||
<admin-page v-else-if="currentView === 'admin'" key="admin" />
|
||||
<not-found-page v-else key="404" />
|
||||
</transition>
|
||||
</main>
|
||||
<app-footer @navigate />
|
||||
</div>`
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
app.component('hms-logo', HmsLogo);
|
||||
app.component('speaker-icon', SpeakerIcon);
|
||||
app.component('app-header', AppHeader);
|
||||
app.component('app-footer', AppFooter);
|
||||
app.component('service-card', ServiceCard);
|
||||
app.component('equipment-card', EquipmentCard);
|
||||
app.component('loading-skeleton', LoadingSkeleton);
|
||||
app.component('empty-state', EmptyState);
|
||||
app.component('error-state', ErrorState);
|
||||
app.component('home-page', HomePage);
|
||||
app.component('referenzen-page', ReferenzenPage);
|
||||
app.component('kontakt-page', KontaktPage);
|
||||
app.component('mietkatalog-page', MietkatalogPage);
|
||||
app.component('equipment-detail-page', EquipmentDetailPage);
|
||||
app.component('warenkorb-page', WarenkorbPage);
|
||||
app.component('admin-page', AdminPage);
|
||||
app.component('not-found-page', NotFoundPage);
|
||||
app.mount('#app');
|
||||
@@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet">
|
||||
<meta name="description" content="HMS Licht & Ton GbR – Veranstaltungstechnik aus Leipheim/Ellzee. Vermietung, Verkauf, Personal, Transport, Installation von Tontechnik, Lichttechnik und Rigging.">
|
||||
<meta name="author" content="Hammerschmidt u. Mössle GbR">
|
||||
<meta name="theme-color" content="#EC6925">
|
||||
|
||||
<!-- OpenGraph Tags -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="HMS Licht & Ton – Veranstaltungstechnik">
|
||||
<meta property="og:description" content="Vermietung, Verkauf und Installation von Veranstaltungstechnik. Über 20 Jahre Erfahrung aus Leipheim/Ellzee.">
|
||||
<meta property="og:locale" content="de_DE">
|
||||
<meta property="og:site_name" content="HMS Licht & Ton">
|
||||
<meta property="og:url" content="https://hms.media-on.de">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="HMS Licht & Ton – Veranstaltungstechnik">
|
||||
<meta name="twitter:description" content="Vermietung, Verkauf und Installation von Veranstaltungstechnik aus Leipheim/Ellzee.">
|
||||
|
||||
<!-- JSON-LD Structured Data -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "LocalBusiness",
|
||||
"@id": "https://hms.media-on.de/#organization",
|
||||
"name": "HMS Licht & Ton",
|
||||
"legalName": "Hammerschmidt u. Mössle GbR",
|
||||
"description": "Veranstaltungstechnik – Vermietung, Verkauf, Personal, Transport, Installation",
|
||||
"url": "https://hms.media-on.de",
|
||||
"logo": "https://hms.media-on.de/logo.svg",
|
||||
"telephone": "+49 8221 204433",
|
||||
"email": "info@hms-licht-ton.de",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "Grockelhofen 10",
|
||||
"addressLocality": "Leipheim",
|
||||
"postalCode": "89340",
|
||||
"addressCountry": "DE"
|
||||
},
|
||||
"location": {
|
||||
"@type": "Place",
|
||||
"name": "Lager Ellzee",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "Zur Schönhalde 8",
|
||||
"addressLocality": "Ellzee",
|
||||
"postalCode": "89352",
|
||||
"addressCountry": "DE"
|
||||
}
|
||||
},
|
||||
"openingHoursSpecification": {
|
||||
"@type": "OpeningHoursSpecification",
|
||||
"dayOfWeek": ["Monday","Tuesday","Wednesday","Thursday","Friday"],
|
||||
"opens": "10:00",
|
||||
"closes": "18:00"
|
||||
},
|
||||
"sameAs": [
|
||||
"https://facebook.com/hmslichtton",
|
||||
"https://instagram.com/hmslichtton"
|
||||
],
|
||||
"makesOffer": [
|
||||
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Equipment-Vermietung"}},
|
||||
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Equipment-Verkauf"}},
|
||||
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Personal-Service"}},
|
||||
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Transport"}},
|
||||
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Lagerung"}},
|
||||
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Werkstatt"}},
|
||||
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Installation"}},
|
||||
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Booking"}}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<title>HMS Licht & Ton – Veranstaltungstechnik Leipheim/Ellzee</title>
|
||||
|
||||
<!-- Tailwind CSS CDN (Production uses Tailwind via Nuxt 3) -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#EC6925',
|
||||
'primary-hover': '#d4581a',
|
||||
'primary-light': '#fef0e8'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Google Fonts: Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Custom Design Tokens -->
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Vue 3 mounts here -->
|
||||
<div class="flex items-center justify-center min-h-screen" role="status" aria-live="polite" aria-busy="true">
|
||||
<div class="text-center">
|
||||
<div class="hms-spinner mx-auto mb-4" style="width:40px;height:40px;border-width:4px"></div>
|
||||
<p class="text-gray-400 text-sm">Laden...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vue 3 CDN (Production uses Nuxt 3 / Vue 3) -->
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
|
||||
<!-- App Components & Router -->
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
playwright-report
|
||||
test-results
|
||||
coverage
|
||||
@@ -0,0 +1,21 @@
|
||||
# --- Stage 1: Build ---
|
||||
FROM node:20 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 2: Production ---
|
||||
FROM node:20-slim AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/.output ./.output
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
@@ -0,0 +1,432 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ============================================
|
||||
HMS Licht & Ton – Design Tokens & Custom CSS
|
||||
Dark Theme + Subtle Orange Accent
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
/* Brand Accent - used sparingly */
|
||||
--color-accent: #FA5C01;
|
||||
--color-accent-hover: #d4581a;
|
||||
--color-accent-light: rgba(236, 105, 37, 0.08);
|
||||
--color-accent-border: rgba(236, 105, 37, 0.25);
|
||||
--color-accent-dark: #b8461a;
|
||||
|
||||
/* Agent Zero Dark Theme Grays */
|
||||
--bg: #131313;
|
||||
--panel: #1a1a1a;
|
||||
--surface: #212121;
|
||||
--row: #272727;
|
||||
--card: #2d2d2d;
|
||||
--border: #444444a8;
|
||||
--secondary: #656565;
|
||||
--primary: #737a81;
|
||||
--text: #ffffff;
|
||||
--text-muted: #d4d4d4;
|
||||
|
||||
/* Aliases */
|
||||
--color-bg: var(--bg);
|
||||
--color-surface: var(--panel);
|
||||
--color-surface-alt: var(--surface);
|
||||
--color-card: var(--card);
|
||||
--color-text: var(--text);
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-text-light: var(--secondary);
|
||||
--color-border: var(--border);
|
||||
--color-border-strong: #555555;
|
||||
--border-strong: #555555;
|
||||
|
||||
/* Status Colors */
|
||||
--color-success: #4ade80;
|
||||
--color-success-bg: rgba(74, 222, 128, 0.08);
|
||||
--color-error: #f87171;
|
||||
--color-error-bg: rgba(248, 113, 113, 0.08);
|
||||
--color-warning: #fbbf24;
|
||||
--color-warning-bg: rgba(251, 191, 36, 0.08);
|
||||
--color-info: #60a5fa;
|
||||
--color-info-bg: rgba(96, 165, 250, 0.08);
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-heading: 'Inter', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
--text-5xl: 3rem;
|
||||
--text-6xl: 3.75rem;
|
||||
|
||||
/* Spacing */
|
||||
--space-xs: 0.25rem;
|
||||
--space-sm: 0.5rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2rem;
|
||||
--space-2xl: 3rem;
|
||||
--space-3xl: 4rem;
|
||||
--space-4xl: 6rem;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 3px;
|
||||
--radius-lg: 4px;
|
||||
--radius-xl: 4px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.3);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 250ms ease;
|
||||
--transition-slow: 400ms ease;
|
||||
|
||||
/* Layout */
|
||||
--header-height: 4rem;
|
||||
--max-width: 1280px;
|
||||
}
|
||||
|
||||
/* Base Reset */
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
line-height: 1.6;
|
||||
font-size: var(--text-base);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Tailwind dark overrides */
|
||||
.text-gray-900 { color: var(--text) !important; }
|
||||
.text-gray-800 { color: var(--text-muted) !important; }
|
||||
.text-gray-700 { color: var(--text-muted) !important; }
|
||||
.text-gray-600 { color: var(--text-muted) !important; }
|
||||
.text-gray-500 { color: var(--secondary) !important; }
|
||||
.text-gray-400 { color: var(--secondary) !important; }
|
||||
.text-white { color: var(--text) !important; }
|
||||
.bg-white { background-color: var(--panel) !important; }
|
||||
.bg-gray-100 { background-color: var(--surface) !important; }
|
||||
.bg-gray-200 { background-color: var(--row) !important; }
|
||||
.bg-gray-800 { background-color: var(--bg) !important; }
|
||||
.bg-gray-900 { background-color: var(--bg) !important; }
|
||||
.border-gray-200 { border-color: var(--border) !important; }
|
||||
.border-gray-700 { border-color: var(--border) !important; }
|
||||
.border-gray-100 { border-color: var(--border) !important; }
|
||||
.divide-gray-100 > * { border-color: var(--border) !important; }
|
||||
|
||||
/* Skip Link */
|
||||
.skip-link {
|
||||
position: absolute; top: -100px; left: 0;
|
||||
background: var(--color-accent); color: white;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
z-index: 9999; border-radius: 0 0 var(--radius-md) 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.skip-link:focus { top: 0; }
|
||||
|
||||
/* Focus visible */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--secondary); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--primary); }
|
||||
|
||||
/* Component: Header */
|
||||
.hms-header {
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
}
|
||||
.hms-logo-svg { height: 40px; width: auto; }
|
||||
.hms-logo-svg rect { stroke: var(--color-accent) !important; }
|
||||
|
||||
/* Component: Hero – with image background, subtle orange */
|
||||
.hms-hero {
|
||||
position: relative;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hms-hero-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.hms-hero-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(19,19,19,0.6) 0%, rgba(19,19,19,0.85) 50%, var(--bg) 100%);
|
||||
}
|
||||
|
||||
/* Component: Card */
|
||||
.hms-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow var(--transition-base), border-color var(--transition-base);
|
||||
}
|
||||
.hms-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
/* Component: Button – orange only on primary, subtle */
|
||||
.hms-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
font-family: var(--font-sans); font-weight: 600;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer; border: none; text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hms-btn-primary {
|
||||
background: var(--color-accent); color: white;
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
box-shadow: 0 0 0 0 rgba(236, 105, 37, 0);
|
||||
}
|
||||
.hms-btn-primary:hover {
|
||||
background: var(--color-accent-hover);
|
||||
box-shadow: 0 0 12px 2px rgba(236, 105, 37, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.hms-btn-primary:active { transform: translateY(0); box-shadow: 0 0 6px 0px rgba(236, 105, 37, 0.2); }
|
||||
.hms-btn-primary:disabled { background: var(--secondary); cursor: not-allowed; box-shadow: none; transform: none; }
|
||||
|
||||
.hms-btn-secondary {
|
||||
background: var(--surface); color: var(--text-muted);
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
}
|
||||
.hms-btn-secondary:hover {
|
||||
background: var(--row);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--text);
|
||||
box-shadow: 0 0 8px 0 rgba(236, 105, 37, 0.1);
|
||||
}
|
||||
.hms-btn-secondary:active { background: var(--surface); box-shadow: none; }
|
||||
|
||||
.hms-btn-ghost {
|
||||
background: transparent; color: var(--text-muted);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
.hms-btn-ghost:hover { background: var(--surface); color: var(--color-accent); border-color: var(--color-accent-border); }
|
||||
|
||||
/* Component: Nav Button (header menu items) */
|
||||
.hms-nav-btn {
|
||||
position: relative;
|
||||
transition: color var(--transition-fast), background-color var(--transition-fast), border-color var(--transition-fast) !important;
|
||||
}
|
||||
.hms-nav-btn:hover {
|
||||
color: var(--color-accent) !important;
|
||||
background: var(--color-accent-light) !important;
|
||||
}
|
||||
.hms-nav-btn:hover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 2px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60%; height: 2px;
|
||||
background: var(--color-accent);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.hms-nav-btn[aria-current="page"]:hover {
|
||||
color: var(--color-accent) !important;
|
||||
background: var(--color-accent-light) !important;
|
||||
}
|
||||
|
||||
/* Component: Badge – subtle orange */
|
||||
.hms-badge {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 2px var(--space-sm);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs); font-weight: 600;
|
||||
}
|
||||
.hms-badge-primary {
|
||||
background: rgba(236, 105, 37, 0.12);
|
||||
color: var(--color-accent);
|
||||
border: 1px solid rgba(236, 105, 37, 0.2);
|
||||
}
|
||||
.hms-badge-gray { background: var(--surface); color: var(--text-muted); }
|
||||
.hms-badge-success { background: var(--color-success-bg); color: var(--color-success); }
|
||||
.hms-badge-error { background: var(--color-error-bg); color: var(--color-error); }
|
||||
|
||||
/* Component: Input */
|
||||
.hms-input {
|
||||
width: 100%;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
background: var(--surface); color: var(--text);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
.hms-input::placeholder { color: var(--secondary); }
|
||||
.hms-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px rgba(236, 105, 37, 0.1);
|
||||
}
|
||||
.hms-input:disabled { background: var(--row); color: var(--secondary); cursor: not-allowed; }
|
||||
.hms-input-error { border-color: var(--color-error); }
|
||||
.hms-input-error:focus { box-shadow: 0 0 0 2px rgba(248, 113, 113, 0.1); }
|
||||
select.hms-input { background: var(--surface); color: var(--text); }
|
||||
select.hms-input option { background: var(--panel); color: var(--text); }
|
||||
|
||||
/* Component: Loading Skeleton */
|
||||
.hms-skeleton {
|
||||
background: linear-gradient(90deg, var(--surface) 25%, var(--row) 50%, var(--surface) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Component: Spinner */
|
||||
.hms-spinner {
|
||||
width: 24px; height: 24px;
|
||||
border: 3px solid var(--surface);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Component: Service Icon Circle – subtle, no large orange area */
|
||||
.hms-icon-circle {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.25rem; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Component: Speaker Grid */
|
||||
.hms-speaker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.hms-speaker-item {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
text-align: center;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
.hms-speaker-item:hover {
|
||||
border-color: var(--color-accent-border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.hms-speaker-icon-wrap { display: inline-flex; align-items: center; justify-content: center; margin: 0 auto var(--space-sm); }
|
||||
.hms-speaker-icon-wrap svg { width: 48px; height: 48px; color: var(--text-muted); transition: color var(--transition-base); }
|
||||
.hms-speaker-item:hover .hms-speaker-icon-wrap svg { color: var(--color-accent); }
|
||||
.hms-speaker-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.hms-speaker-type {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
/* Component: Footer */
|
||||
.hms-footer {
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Gallery Grid */
|
||||
.hms-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
/* Equipment Card */
|
||||
.hms-eq-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transition: all var(--transition-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
.hms-eq-card:hover {
|
||||
border-color: var(--color-accent-border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Filter Chip */
|
||||
.hms-chip {
|
||||
display: inline-flex; align-items: center; gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm); font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface); color: var(--text-muted);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.hms-chip:hover { border-color: var(--secondary); }
|
||||
.hms-chip.active {
|
||||
background: var(--color-accent); color: white;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Section divider – subtle */
|
||||
.hms-section-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent 0%, var(--border) 50%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Mobile Menu Animation */
|
||||
.mobile-menu-enter-active, .mobile-menu-leave-active { transition: all var(--transition-base); }
|
||||
.mobile-menu-enter-from, .mobile-menu-leave-to { opacity: 0; transform: translateY(-10px); }
|
||||
|
||||
/* Fade Transition */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity var(--transition-base); }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
/* Link colors */
|
||||
a { color: var(--color-accent); }
|
||||
a:hover { color: var(--color-accent-hover); }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
:root { --header-height: 3.5rem; }
|
||||
.hms-speaker-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user