You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

40 KiB

Tech Stack Dokumentation

my.experimenta.science

Version: 1.0 Datum: 28. Oktober 2025


Übersicht

Dieser Dokument beschreibt den vollständigen Tech Stack für die my.experimenta.science E-Commerce App und begründet die getroffenen Entscheidungen.


1. Frontend Framework

Nuxt 4

Entscheidung: Nuxt 4 (Vue.js Meta-Framework)

Begründung:

  • Vue 3 Composition API: Moderne, reaktive Architektur
  • Server-Side Rendering (SSR): Bessere Performance & SEO
  • File-based Routing: Intuitive Projektstruktur
  • Auto-Import: Weniger Boilerplate-Code
  • Hybrid Rendering: Flexible Rendering-Modi (SSR, SSG, CSR)
  • TypeScript Support: Native TypeScript-Integration
  • Server APIs: Backend & Frontend in einem Projekt
  • Active Development: Nuxt 4 ist die neueste Version mit langfristigem Support

Alternativen:

  • Next.js (React): Großartig, aber Team bevorzugt Vue
  • SvelteKit: Weniger Ecosystem, kleinere Community
  • Remix: Noch jünger, weniger etabliert

Version: ^4.0.0


2. UI Framework

Optionen: Nuxt UI vs. shadcn-vue

Option A: Nuxt UI

Pro:

  • Speziell für Nuxt entwickelt
  • Out-of-the-box Integration
  • Headless UI Components
  • Tailwind CSS basiert
  • Dark Mode Support
  • Accessibility eingebaut
  • Icons & Colors mitgeliefert
  • Nuxt-native (optimiert)

Contra:

  • Kleinere Component-Bibliothek
  • Weniger flexibel als shadcn
  • Stärkere Meinung zum Styling

Best for: Schneller Start, weniger Customization


Option B: shadcn-nuxt

Pro:

  • Copy-Paste Components (volle Kontrolle)
  • Radix Vue (hochwertige Primitives)
  • Vollständig anpassbar
  • Kein Package Lock-in
  • Große Component-Bibliothek
  • Sehr flexibel
  • Hervorragende TypeScript-Support
  • Nuxt-native: Offizielle Nuxt-Integration

Contra:

  • Manuelles Hinzufügen von Components via CLI
  • Mehr Setup als Nuxt UI

Best for: Maximale Anpassung, langfristige Wartbarkeit


Empfehlung: shadcn-nuxt

Grund:

  • Corporate Design Integration benötigt maximale Flexibilität
  • Copy-Paste-Ansatz = kein Vendor Lock-in
  • Vollständige Kontrolle über Komponenten
  • Einfacher zu debuggen (Code liegt im Projekt)
  • Bessere Anpassung an experimenta Styleguide möglich
  • Offizielle Nuxt-Module Integration

Installation:

npx nuxi@latest module add shadcn-nuxt
npx shadcn-nuxt@latest init

Official Docs: https://www.shadcn-vue.com/docs/installation/nuxt.html

Dependencies:

  • shadcn-nuxt - Nuxt module
  • radix-vue - UI Primitives (auto-installed)
  • tailwindcss - Utility-first CSS
  • class-variance-authority - Varianten-Management
  • tailwind-merge - Classname-Merging
  • clsx - Conditional Classes

3. Styling

Tailwind CSS

Entscheidung: Tailwind CSS v4 (Beta)

Begründung:

  • Utility-First: Schnelle Entwicklung
  • Responsive Design: Mobile-first eingebaut
  • Customization: Einfache Theme-Anpassung
  • Performance: PurgeCSS integriert (kleine Bundles)
  • Developer Experience: Hervorragende IDE-Integration (Intellisense)
  • Konsistenz: Design-Tokens erzwingen Konsistenz

Corporate Design Integration:

// tailwind.config.js
export default {
  theme: {
    extend: {
      colors: {
        experimenta: {
          primary: '#YOUR_PRIMARY_COLOR',
          secondary: '#YOUR_SECONDARY_COLOR',
          // ... weitere Farben aus Styleguide
        },
      },
      fontFamily: {
        sans: ['Your Corporate Font', 'sans-serif'],
      },
    },
  },
}

4. Datenbank

PostgreSQL

Entscheidung: PostgreSQL 16+

Begründung:

  • Relational: Klare Datenstrukturen für E-Commerce
  • ACID-Compliant: Transaktionssicherheit für Bestellungen
  • Robust: Bewährte Technologie
  • Performance: Exzellent für Read-Heavy Workloads
  • JSON Support: Flexibilität für metadata
  • Full-Text Search: Native Suchfunktionen
  • Extensions: PostGIS, pg_trgm, etc.
  • Open Source: Keine Lizenzkosten

Hosting:

  • Docker Container auf Hetzner Proxmox
  • Persistent Volume für Daten
  • Automated Backups (tägliche Snapshots)

Connection Pooling:

  • pg_bouncer für effizientes Connection Management

5. ORM

Drizzle ORM

Entscheidung: Drizzle ORM

Begründung:

  • TypeScript-First: Native TypeScript ohne Code-Generation
  • Performance: Minimal Runtime Overhead
  • SQL-Like Syntax: Leicht zu lernen für SQL-Kenner
  • Type Safety: Compile-time Checks
  • PostgreSQL-Optimiert: Nutzt PostgreSQL-Features voll aus
  • Drizzle Studio: GUI für DB-Verwaltung
  • Migrations: Einfache Schema-Verwaltung
  • Lightweight: Keine schwere Runtime wie Prisma

Alternativen:

  • Prisma: Mehr Overhead, langsamer, aber größere Community
  • TypeORM: Veraltet, weniger TypeScript-fokussiert
  • Kysely: Gut, aber weniger Features

Setup:

// drizzle.config.ts
export default {
  schema: './server/database/schema.ts',
  out: './server/database/migrations',
  driver: 'pg',
  dbCredentials: {
    connectionString: process.env.DATABASE_URL,
  },
}

Version: ^0.29.0


5.1 Data Storage Design: Billing Address

Entscheidung: Structured Columns in Users Table

Problem: Should we store billing addresses in the user profile or only in orders?

Entscheidung: Store billing address directly in the users table as structured columns (not JSONB).

Begründung:

Pro Speicherung im Profil:

  • UX-Verbesserung: Wiederkehrende Kunden müssen Daten nicht erneut eingeben
  • Conversion-Rate: Reduziert Checkout-Abbrüche (10-20% lt. Branchenstandard)
  • Mobile-First: Schnellerer Checkout ist kritisch für mobile Nutzer
  • Geschäftsmodell: Jahreskarten = wiederkehrende Kunden (jährliche Erneuerung)
  • E-Commerce Best Practice: Amazon, PayPal, alle großen Shops speichern Adressen
  • DSGVO-konform: Rechtsgrundlage "Vertragserfüllung" (Art. 6(1)(b))

Strukturierte Spalten vs. JSONB:

Aspekt Structured Columns JSONB Column
Type Safety Voll typsicher ⚠️ Zod-Validierung nötig
Queries Einfach zu filtern/sortieren Komplexere Queries
Performance Besser indexierbar ⚠️ Langsamer
Flexibility Schema-Änderungen = Migration Flexibel
X-API Mapping Direkt mappbar ⚠️ Parsing nötig

Entscheidung: Strukturierte Spalten

Felder:

export const users = pgTable('users', {
  // ... existing fields

  // Billing address (optional - filled during checkout)
  salutation: varchar('salutation', { length: 20 }), // 'male', 'female', 'other'
  dateOfBirth: date('date_of_birth'), // Required for annual passes
  street: varchar('street', { length: 100 }),
  postCode: varchar('post_code', { length: 20 }),
  city: varchar('city', { length: 100 }),
  countryCode: varchar('country_code', { length: 10 }).default('DE'),
})

UX Flow:

  1. First Checkout: User fills address form, checkbox "Save for future orders" is pre-checked
  2. Subsequent Checkouts: Form pre-filled, editable before submission
  3. Profile Management: /profil/adresse page to view/edit/delete address

Migration Strategy:

  • Address fields are optional in DB schema
  • Required validation only at checkout (Zod)
  • Existing users: Backfill from most recent order (optional)

Privacy:

  • Opt-out via unchecking checkbox at checkout
  • Can be deleted in profile settings
  • Included in GDPR data export/deletion

5.2 Queue System: BullMQ

Entscheidung: BullMQ (MIT License)

Problem: Asynchrone Verarbeitung von zeitaufwendigen Operationen (X-API Order Submission, NAV Product Sync).

Entscheidung: BullMQ als Queue-System mit Redis als Backend.

Begründung:

Pro BullMQ:

  • TypeScript-First: Native TypeScript-Integration, perfekt für Nuxt
  • Einfache Integration: npm package, kein separater Broker-Service
  • Redis-basiert: Nutzt Redis (für Sessions/Caching eh sinnvoll)
  • MIT License: Kostenlos, echtes Open Source
  • Features:
    • Retry Logic mit exponential backoff
    • Priority Queues
    • Delayed Jobs
    • Rate Limiting
    • Dead Letter Queue
    • Flow Control (Job Chains)
  • Monitoring: BullBoard (kostenlos) für Web-Dashboard
  • Performance: 10.000+ Jobs/Sekunde (ausreichend)
  • Community: Große, aktive Community
  • Production-Ready: Von vielen Unternehmen eingesetzt

Alternativen:

Lösung Pro Contra Empfehlung
BullMQ TypeScript, einfach, MIT Nur Node.js Empfohlen
RabbitMQ Robust, Multi-Language Zu komplex, separater Service Overkill
Database Queue Keine neue Dependency Weniger Features, Polling Nicht skalierbar
Kafka Extreme Skalierung Viel zu komplex Overkill

Für my.experimenta.science ist BullMQ ideal:

  • Nuxt-only (Node.js)
  • Einfache Use Cases (Order → X-API, Product Sync)
  • Moderater Traffic (~10-500 Orders/Tag)
  • Redis eh nützlich (Sessions, Caching)

Use Cases

1. Order Submission (Async)

// server/queues/orderQueue.ts
import { Queue } from 'bullmq'

export const orderQueue = new Queue('x-api-orders', {
  connection: { host: 'redis', port: 6379 },
})

// Add job after PayPal success
export async function queueOrderSubmission(orderId: string) {
  await orderQueue.add(
    'submit-order',
    { orderId },
    {
      attempts: 5,
      backoff: { type: 'exponential', delay: 2000 },
      removeOnComplete: 1000,
      removeOnFail: false, // Keep for manual review
    }
  )
}

2. Product Sync (Async)

// server/queues/productQueue.ts
export const productSyncQueue = new Queue('product-sync', {
  connection: { host: 'redis', port: 6379 },
})

// API endpoint: POST /api/erp/products
export default defineEventHandler(async (event) => {
  const product = await readBody(event)

  // Validate & queue (instant response!)
  await productSyncQueue.add('sync-product', product, {
    attempts: 3,
    backoff: { type: 'exponential', delay: 1000 },
  })

  return { status: 'accepted', queued: true }
})

3. Worker Implementation

// server/workers/orderWorker.ts
import { Worker } from 'bullmq'

export const orderWorker = new Worker(
  'x-api-orders',
  async (job) => {
    const { orderId } = job.data

    // 1. Fetch order from DB
    const order = await db.query.orders.findFirst({
      where: eq(orders.id, orderId),
      with: { items: true, user: true },
    })

    // 2. Transform & submit to X-API
    const payload = transformOrderToXAPI(order)
    await submitToXAPI(payload)

    // 3. Update status
    await db.update(orders).set({ status: 'completed' }).where(eq(orders.id, orderId))
  },
  {
    connection: { host: 'redis', port: 6379 },
    concurrency: 5,
    limiter: { max: 10, duration: 1000 },
  }
)

Monitoring: BullBoard

// server/api/admin/queues.ts
import { createBullBoard } from '@bull-board/api'
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'
import { NuxtAdapter } from '@bull-board/nuxt'

const serverAdapter = new NuxtAdapter()

createBullBoard({
  queues: [new BullMQAdapter(orderQueue), new BullMQAdapter(productSyncQueue)],
  serverAdapter,
})

// Access: http://localhost:3000/admin/queues

Dashboard Features (kostenlos):

  • Real-time Job-Status
  • Manual Retry
  • Job Details & Logs
  • Queue-Metriken
  • Fehler-Analyse

BullMQ Dependencies

{
  "dependencies": {
    "bullmq": "^5.0.0",
    "@bull-board/api": "^5.0.0",
    "@bull-board/nuxt": "^5.0.0"
  }
}

Alle MIT License - kostenlos!

BullMQ Kosten

BullMQ: €0 (MIT License) BullMQ Pro: €99-299/Monat (nicht nötig!) Taskforce.sh: €49-199/Monat (BullBoard reicht!)

Für dich: €0 - alles kostenlos!


5.3 In-Memory Store: Redis

Entscheidung: Redis 7

Verwendung:

  1. BullMQ Queue Storage (Hauptzweck)
  2. Session Storage (HTTP-only cookies)
  3. Caching (Produkt-Daten, etc.)
  4. Rate Limiting (Brute-Force-Schutz)

Begründung:

  • Persistenz mit AOF + RDB (keine Datenverluste)
  • Schnell (In-Memory)
  • Bewährt (Millionen Installationen)
  • Docker-ready (redis:7-alpine)

Lizenz-Situation:

Redis License (seit März 2024):

  • RSALv2 (Redis Source Available License)
  • SSPL (Server Side Public License)

Dein Use Case:

Vollständig legal und kostenlos!

Was du machst:

  • Redis selbst hosten (in docker-compose)
  • Für interne App-Funktionen nutzen
  • Nicht als Hosting-Service verkaufen

Keine Lizenzkosten, keine Probleme!

Was verboten wäre:

  • "experimenta Redis Cloud" Service anbieten
  • Managed Redis Hosting verkaufen

Alternative: Valkey (100% Open Source)

Falls Lizenz-Unsicherheit:

  • Valkey: Redis-Fork (Linux Foundation)
  • Lizenz: BSD-3-Clause (echtes Open Source)
  • Kompatibilität: 100% Drop-in Replacement
  • Docker: valkey/valkey:7.2-alpine

Empfehlung: Start mit Redis, später optional auf Valkey migrieren.

Persistenz-Konfiguration

# docker-compose.yml
services:
  redis:
    image: redis:7-alpine
    command: >
      redis-server
      --appendonly yes
      --appendfsync everysec
      --save 60 1000
      --save 300 100
      --save 900 1      
    volumes:
      - redis_data:/data
    restart: unless-stopped
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
      interval: 10s

volumes:
  redis_data:

Garantie: Max. 1 Sekunde Datenverlust bei Server-Crash.

Erklärung:

  • --appendonly yes: AOF aktiviert
  • --appendfsync everysec: Jede Sekunde auf Disk
  • --save: RDB Snapshots als Backup

Redis Dependencies

{
  "dependencies": {
    "ioredis": "^5.3.0" // Redis client für Node.js
  }
}

Redis Kosten

Redis/Valkey: €0 (Open Source) Einzige Kosten: Server-RAM (~512MB für Redis)


6. Authentifizierung

Cidaas (OIDC/OAuth2)

Entscheidung: Cidaas Integration mit Custom UI

Begründung:

  • Unternehmensvorgabe: Bereits im Einsatz bei experimenta
  • OIDC/OAuth2: Standard-Protokolle
  • Compliance: DSGVO-konform (EU-basiert, Widas)
  • Features: MFA, Social Login (optional)

Architektur:

  • Cidaas: Nur für Authentifizierung (User Identity)
  • Lokale DB: User-Profile & Rollen
  • Custom UI: Eigene Login/Registrierungs-Masken im experimenta Design

Integration:

// Nuxt OAuth Module oder Custom Implementation
import { createOAuth2Client } from '@nuxt/oauth'

export default defineNuxtConfig({
  oauth: {
    cidaas: {
      clientId: process.env.CIDAAS_CLIENT_ID,
      clientSecret: process.env.CIDAAS_CLIENT_SECRET,
      authorizeUrl: 'https://experimenta.cidaas.de/authorize',
      tokenUrl: 'https://experimenta.cidaas.de/token',
      userInfoUrl: 'https://experimenta.cidaas.de/userinfo',
    },
  },
})

Libraries:

  • nuxt-auth-utils oder Custom OAuth2 Implementation
  • jose für JWT-Validierung

7. Payment Gateway

PayPal (MVP)

Entscheidung: PayPal Checkout Integration

Begründung:

  • Weit verbreitet: Hohe Akzeptanz in Deutschland
  • Einfache Integration: Offizielle SDK & Dokumentation
  • Buyer Protection: Käuferschutz erhöht Vertrauen
  • Schnell: Weniger Daten als Kreditkarte erforderlich
  • Mobile-optimiert: PayPal App-Integration

SDK:

// PayPal JavaScript SDK
<script src="https://www.paypal.com/sdk/js?client-id=YOUR_CLIENT_ID&currency=EUR"></script>

Server-Side:

  • PayPal REST API für Order Creation & Capture
  • Webhook für Payment-Benachrichtigungen

Post-MVP:

  • Stripe (Kreditkarte, Apple Pay, Google Pay)
  • SEPA-Lastschrift
  • Klarna / Ratenzahlung

8. External API Integration: X-API

X-API Client (Order Submission)

Entscheidung: Native fetch with retry logic

Purpose: Submit completed orders to NAV ERP via X-API /shopware/order endpoint

Begründung:

  • Native: No additional dependencies (fetch is built-in)
  • TypeScript: Full type safety with Zod validation
  • Retry Logic: Custom implementation with exponential backoff
  • Error Handling: Comprehensive error tracking and logging

Environments:

Environment URL
Development https://x-api-dev.experimenta.science
Staging https://x-api-stage.experimenta.science
Production https://x-api.experimenta.science

X-API Authentication

Entscheidung: HTTP Basic Authentication

Begründung:

  • Standard: HTTP Basic Auth ist ein bewährter Standard (RFC 7617)
  • Einfach: Keine komplexe OAuth2-Integration nötig
  • Secure: HTTPS-verschlüsselte Übertragung der Credentials
  • Kompatibel: Unterstützt von allen HTTP-Clients

Implementierung:

// server/utils/xapi.ts
const config = useRuntimeConfig()

// Prepare Basic Auth header
const authString = Buffer.from(`${config.xApiUsername}:${config.xApiPassword}`).toString('base64')

const response = await fetch(`${config.xApiBaseUrl}/shopware/order`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Basic ${authString}`,
  },
  body: JSON.stringify(payload),
})

Sicherheitshinweise:

  • Credentials nur über HTTPS übertragen
  • Credentials niemals im Code hardcoden
  • Separate Service-Accounts pro Environment (dev/stage/prod)
  • Credentials regelmäßig rotieren (alle 90 Tage)
  • In Production: Docker Secrets verwenden statt Environment Variables

Environment Variables:

# .env
X_API_BASE_URL=https://x-api-dev.experimenta.science
X_API_USERNAME=shop_user_dev
X_API_PASSWORD=xxx

Docker Secrets (Production):

# docker-compose.yml
services:
  app:
    secrets:
      - xapi_username
      - xapi_password
    environment:
      X_API_USERNAME_FILE: /run/secrets/xapi_username
      X_API_PASSWORD_FILE: /run/secrets/xapi_password

secrets:
  xapi_username:
    file: ./secrets/xapi_username.txt
  xapi_password:
    file: ./secrets/xapi_password.txt

Implementation:

// server/utils/xapi.ts
import { z } from 'zod'

// Zod schema for X-API request validation
const XAPIOrderSchema = z.object({
  shopPOSOrder: z.object({
    documentType: z.enum(['Order', 'Credit Memo']),
    externalDocumentNo: z.string().max(35),
    externalPOSReceiptNo: z.string().max(250),
    salesChannel: z.enum(['Shop', 'POS']),
    shoppingCartCompletion: z.string().datetime(),
    visitType: z.enum(['Private', 'Institution']),
    invoiceDiscountValue: z.number().int(),
    invoiceDiscPct: z.number().int(),
    amountIncludingVAT: z.number().int(), // Cents!
    language: z.enum(['DEU', 'ENU']),
    salesLine: z.array(
      z.object({
        type: z.enum(['', 'Item']),
        lineNo: z.string().max(30),
        no: z.string().max(20),
        variantCode: z.string().max(10),
        description: z.string().max(50),
        description2: z.string().max(50),
        quantity: z.number().int(),
        unitPrice: z.number().int(), // Cents!
        vatPct: z.number(),
        lineAmountIncludingVAT: z.number(),
        lineDiscountPct: z.number(),
        lineDiscountAmount: z.number().int(),
        seasonTicketCode: z.string().max(20),
        couponCode: z.string().max(20),
        visitorCategory: z.enum(['00', '10', '40', '50', '60', '99', '120', '128']),
        ticketPriceType: z.string().max(50),
        ticketCode: z.string().max(250),
        annualPass: z
          .object({
            salutationCode: z.enum(['K_ANGABE', 'FRAU', 'HERR']),
            firstName: z.string().max(30),
            lastName: z.string().max(30),
            streetAndHouseNumber: z.string().max(50),
            postCode: z.string().max(20),
            city: z.string().max(30),
            countryCode: z.string().max(10),
            dateOfBirth: z.string(),
            eMail: z.string().email(),
            validFrom: z.string(),
          })
          .optional(),
      })
    ),
    payment: z.array(
      z.object({
        paymentEntryNo: z.number().int(),
        amount: z.number().int(), // Cents!
        paymentType: z.enum(['Shop PayPal', 'Shop Credit Card', 'Shop Direct Debit']),
        createdOn: z.string().datetime(),
        reference: z.string().max(50),
        paymentId: z.string().max(50),
      })
    ),
    personContact: z.object({
      experimentaAccountID: z.string().uuid(),
      eMail: z.string().email(),
      salutationCode: z.enum(['K_ANGABE', 'FRAU', 'HERR']),
      jobTitle: z.string().max(30),
      firstName: z.string().max(30),
      lastName: z.string().max(30),
      streetAndHouseNumber: z.string().max(50),
      postCode: z.string().max(20),
      city: z.string().max(30),
      countryCode: z.string().max(10),
      phoneNumber: z.string().max(30),
      guest: z.boolean(),
    }),
  }),
})

export type XAPIOrderPayload = z.infer<typeof XAPIOrderSchema>

// Submit order to X-API with retry logic
export async function submitOrderToXAPI(payload: XAPIOrderPayload) {
  // Validate payload
  const validatedPayload = XAPIOrderSchema.parse(payload)

  const config = useRuntimeConfig()
  const baseURL = config.xApiBaseUrl // From environment

  // Retry logic with exponential backoff
  const maxRetries = 3
  const retryDelays = [1000, 3000, 9000] // 1s, 3s, 9s

  // Prepare Basic Auth header
  const authString = Buffer.from(`${config.xApiUsername}:${config.xApiPassword}`).toString('base64')

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fetch(`${baseURL}/shopware/order`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Basic ${authString}`,
        },
        body: JSON.stringify(validatedPayload),
      })

      if (!response.ok) {
        throw new Error(`X-API returned ${response.status}: ${await response.text()}`)
      }

      const result = await response.json()

      // Log success
      console.info(`Order submitted to X-API successfully`, {
        orderNo: payload.shopPOSOrder.externalDocumentNo,
        attempt: attempt + 1,
      })

      return result
    } catch (error) {
      // Log attempt
      console.error(`X-API submission failed (attempt ${attempt + 1}/${maxRetries})`, {
        orderNo: payload.shopPOSOrder.externalDocumentNo,
        error,
      })

      // If this was the last attempt, throw
      if (attempt === maxRetries - 1) {
        throw error
      }

      // Wait before retry
      await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]))
    }
  }
}

// Helper: Transform order from DB to X-API format
export function transformOrderToXAPI(order: Order, user: User): XAPIOrderPayload {
  return {
    shopPOSOrder: {
      documentType: 'Order',
      externalDocumentNo: order.orderNumber,
      externalPOSReceiptNo: '',
      salesChannel: 'Shop',
      shoppingCartCompletion: order.createdAt.toISOString(),
      visitType: 'Private',
      invoiceDiscountValue: 0,
      invoiceDiscPct: 0,
      amountIncludingVAT: Math.round(order.totalAmount * 100), // EUR → Cents
      language: 'DEU',
      salesLine: order.items.map((item, index) => ({
        type: 'Item',
        lineNo: String((index + 1) * 10000),
        no: item.product.navProductId,
        variantCode: item.product.variantCode || '',
        description: item.product.name.substring(0, 50),
        description2: item.product.description?.substring(0, 50) || '',
        quantity: item.quantity,
        unitPrice: Math.round(item.priceSnapshot * 100), // EUR → Cents
        vatPct: 7,
        lineAmountIncludingVAT: Math.round(item.quantity * item.priceSnapshot * 100),
        lineDiscountPct: 0,
        lineDiscountAmount: 0,
        seasonTicketCode: '',
        couponCode: '',
        visitorCategory: '120',
        ticketPriceType: 'Jahreskarte Makerspace',
        ticketCode: generateTicketCode(),
        annualPass: {
          salutationCode: mapSalutation(user.salutation),
          firstName: user.firstName,
          lastName: user.lastName,
          streetAndHouseNumber: user.address.street,
          postCode: user.address.postCode,
          city: user.address.city,
          countryCode: user.address.countryCode || 'DE',
          dateOfBirth: user.dateOfBirth,
          eMail: user.email,
          validFrom: new Date().toISOString().split('T')[0], // YYYY-MM-DD
        },
      })),
      payment: [
        {
          paymentEntryNo: 10000,
          amount: Math.round(order.totalAmount * 100), // EUR → Cents
          paymentType: 'Shop PayPal',
          createdOn: order.paymentCompletedAt.toISOString(),
          reference: order.paymentId,
          paymentId: order.paymentId,
        },
      ],
      personContact: {
        experimentaAccountID: user.experimentaId,
        eMail: user.email,
        salutationCode: mapSalutation(user.salutation),
        jobTitle: user.jobTitle || '',
        firstName: user.firstName,
        lastName: user.lastName,
        streetAndHouseNumber: order.billingAddress.street,
        postCode: order.billingAddress.postCode,
        city: order.billingAddress.city,
        countryCode: order.billingAddress.countryCode || 'DE',
        phoneNumber: user.phone || '',
        guest: false,
      },
    },
  }
}

Environment Variables:

# .env (Development/Staging)
X_API_BASE_URL=https://x-api-dev.experimenta.science
X_API_USERNAME=shop_user_dev
X_API_PASSWORD=xxx

# .env.stage
# X_API_BASE_URL=https://x-api-stage.experimenta.science
# X_API_USERNAME=shop_user_stage
# X_API_PASSWORD=xxx

# Production: Use Docker Secrets (see X-API Authentication section above)

Alternative: Use ofetch (Nuxt's built-in fetch wrapper) for better error handling:

import { ofetch } from 'ofetch'

const xapi = ofetch.create({
  baseURL: config.xApiBaseUrl,
  retry: 3,
  retryDelay: 1000,
})

9. Backend & APIs

Nuxt Server APIs

Entscheidung: Nuxt Server Routes (Nitro)

Begründung:

  • Monorepo: Backend & Frontend in einem Projekt
  • Serverless-Ready: Kann als Serverless Functions deployed werden
  • Auto-Import: Shared Utils zwischen Client & Server
  • TypeScript: Type-Safety über Frontend/Backend-Grenze
  • Performance: Nitro Engine (schneller als Express)
  • Flexible Deployment: Node.js, Deno, Workers, Docker

API Struktur:

server/
  api/
    auth/
      login.post.ts
      callback.get.ts
    products/
      index.get.ts
      [id].get.ts
    cart/
      items.post.ts
      items/[id].patch.ts
    orders/
      index.post.ts
      [id].get.ts
    payment/
      paypal/
        create.post.ts
        capture.post.ts
        webhook.post.ts
    erp/
      products.post.ts

9. Internationalization (i18n)

@nuxtjs/i18n

Entscheidung: @nuxtjs/i18n (Official Nuxt module)

Begründung:

  • Offizielles Nuxt-Modul: Beste Integration mit Nuxt 4
  • Vue I18n powered: Industry-Standard für Vue.js
  • SEO-optimiert: Automatische locale routes, hreflang tags
  • SSR/SSG Support: Perfekt für Nuxt's rendering modes
  • Lazy Loading: Sprachdateien on-demand laden
  • TypeScript Support: Vollständig typisiert
  • Auto-Import: Composables wie useI18n(), $t() direkt verfügbar

Supported Languages:

  • 🇩🇪 Deutsch (Primary, default locale)
  • 🇬🇧 English (Secondary, für internationale Besucher)

Installation:

npx nuxi@latest module add i18n

Configuration:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],

  i18n: {
    locales: [
      {
        code: 'de',
        iso: 'de-DE',
        name: 'Deutsch',
        file: 'de-DE.json',
      },
      {
        code: 'en',
        iso: 'en-US',
        name: 'English',
        file: 'en-US.json',
      },
    ],
    defaultLocale: 'de',
    strategy: 'prefix_except_default', // URLs: /produkte (de), /en/products (en)
    langDir: 'locales',
    lazy: true,
    detectBrowserLanguage: {
      useCookie: true,
      cookieKey: 'i18n_redirected',
      redirectOn: 'root',
    },
  },
})

Project Structure:

/locales
  ├── de-DE.json    # German translations
  └── en-US.json    # English translations

Translation Files Example:

// locales/de-DE.json
{
  "nav": {
    "home": "Startseite",
    "products": "Produkte",
    "cart": "Warenkorb"
  },
  "product": {
    "addToCart": "In den Warenkorb",
    "price": "Preis",
    "description": "Beschreibung"
  },
  "checkout": {
    "title": "Zur Kasse",
    "total": "Gesamt",
    "payNow": "Jetzt bezahlen"
  }
}

// locales/en-US.json
{
  "nav": {
    "home": "Home",
    "products": "Products",
    "cart": "Cart"
  },
  "product": {
    "addToCart": "Add to Cart",
    "price": "Price",
    "description": "Description"
  },
  "checkout": {
    "title": "Checkout",
    "total": "Total",
    "payNow": "Pay Now"
  }
}

Usage in Components:

<template>
  <div>
    <h1>{{ $t('nav.home') }}</h1>
    <p>{{ $t('product.description') }}</p>

    <!-- With parameters -->
    <p>{{ $t('product.price', { amount: formatPrice(99.0) }) }}</p>

    <!-- Language switcher -->
    <USelectMenu v-model="locale" :options="availableLocales">
      <template #label>
        {{ locale === 'de' ? '🇩🇪 Deutsch' : '🇬🇧 English' }}
      </template>
    </USelectMenu>
  </div>
</template>

<script setup lang="ts">
const { locale, t, availableLocales } = useI18n()

// Format price according to locale
const formatPrice = (price: number) => {
  return new Intl.NumberFormat(locale.value, {
    style: 'currency',
    currency: 'EUR',
  }).format(price)
}

// Format date according to locale
const formatDate = (date: Date) => {
  return new Intl.DateTimeFormat(locale.value, {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  }).format(date)
}
</script>

SEO Benefits:

  • Automatische Routes: /produkte (de), /en/products (en)
  • Automatische <link rel="alternate" hreflang="de-DE" /> Tags
  • Locale-specific meta tags
  • Sitemap mit allen Sprachen

X-API Integration:

// server/utils/xapi.ts
import { useI18n } from '#i18n'

export function transformOrderToXAPI(order: Order, user: User) {
  const { locale } = useI18n()

  return {
    shopPOSOrder: {
      // Map locale to X-API language format
      language: locale.value === 'de' ? 'DEU' : 'ENU',
      // ... rest of payload
    },
  }
}

Version: ^8.0.0

Official Docs: https://i18n.nuxtjs.org/


10. Validation & Forms

Zod + VeeValidate

Entscheidung:

  • Zod: Schema Validation
  • VeeValidate: Form Handling

Begründung:

  • Type Safety: Zod generiert TypeScript-Types
  • Reusable Schemas: Validierung auf Client & Server
  • Error Handling: Strukturierte Error-Messages
  • VeeValidate: Vue-native Form Library
  • Composition API Support: Moderne Vue 3 Syntax

Example:

import { z } from 'zod'

export const checkoutSchema = z.object({
  firstName: z.string().min(2),
  lastName: z.string().min(2),
  email: z.string().email(),
  address: z.string().min(5),
})

export type CheckoutForm = z.infer<typeof checkoutSchema>

Dependencies:

  • zod - Schema validation
  • vee-validate - Form handling
  • @vee-validate/zod - Integration

10. State Management

Pinia (Optional)

Entscheidung: Pinia für globale State (sparsam einsetzen)

Begründung:

  • Vue 3 Native: Offizielle State Library für Vue
  • TypeScript: Hervorragende Type-Inference
  • Devtools: Vue Devtools Integration
  • Composition API: Moderne Syntax
  • SSR-Ready: Nuxt Auto-Import

Verwendung:

  • User Store (Auth-Status)
  • Cart Store (Warenkorb)
  • Minimal halten, meiste State in Composables

Alternative:

  • Nuxt Composables (useState) für einfache Cases

11. Deployment & Infrastructure

Docker

Entscheidung: Docker Containerisierung

Begründung:

  • Konsistenz: Gleiche Umgebung überall (Dev, Staging, Prod)
  • Portabilität: Läuft auf Hetzner, AWS, überall
  • Isolation: Keine Konflikte mit anderen Services
  • Skalierung: Einfach mehrere Container starten

Dockerfile (Multi-Stage Build):

# Build Stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production Stage
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/.output ./
EXPOSE 3000
CMD ["node", "server/index.mjs"]

Docker Compose (für Dev):

services:
  app:
    build: .
    ports:
      - '3000:3000'
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/experimenta
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB=experimenta
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass

volumes:
  postgres_data:

Hetzner Cloud

Hosting:

  • Hetzner Dedicated Server oder VPS
  • Proxmox für Virtualisierung
  • Docker Container für App & DB
  • Nginx/Traefik als Reverse Proxy
  • Let's Encrypt für SSL

Empfohlene Server-Specs (Production):

  • CPU: 4 Cores
  • RAM: 8 GB
  • Storage: 160 GB SSD
  • Backup: Tägliche Snapshots

CI/CD mit GitLab

Pipeline Stages:

  1. Build: Docker Image bauen
  2. Test: Unit & E2E Tests laufen
  3. Deploy Staging: Automatisch auf Staging deployen
  4. Deploy Production: Manuell getriggert (approval)

Deployment-Optionen:

Option A: GitLab Runner auf Server

  • Runner auf Hetzner Server installiert
  • Direkter Zugriff zu Docker
  • Schnelleres Deployment

Option B: SSH-Zugriff

  • Runner im GitLab-Server
  • Deployment via SSH + rsync/docker commands
  • Mehr Isolation

Empfehlung: Runner auf Server (Option A)

.gitlab-ci.yml:

stages:
  - build
  - test
  - deploy

build:
  stage: build
  script:
    - docker build -t experimenta-app:$CI_COMMIT_SHA .
  only:
    - main

test:
  stage: test
  script:
    - npm ci
    - npm run test

deploy_production:
  stage: deploy
  script:
    - docker pull experimenta-app:$CI_COMMIT_SHA
    - docker stop experimenta-app || true
    - docker rm experimenta-app || true
    - docker run -d --name experimenta-app -p 3000:3000 experimenta-app:$CI_COMMIT_SHA
  only:
    - main
  when: manual

12. Testing

Vitest

Entscheidung: Vitest für Unit & Integration Tests

Begründung:

  • Vite-Native: Gleiche Config wie Nuxt
  • Fast: Parallelisierung, Watch Mode
  • Vue Support: @vue/test-utils Integration
  • TypeScript: Native TS-Support
  • API kompatibel mit Jest: Einfache Migration

Config:

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    globals: true,
    environment: 'jsdom',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      threshold: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80,
      },
    },
  },
})

Playwright

Entscheidung: Playwright für E2E Tests

Begründung:

  • Modern: Neueste Browser-Automation
  • Multi-Browser: Chrome, Firefox, Safari, Edge
  • Mobile Emulation: Responsive Testing
  • Fast: Parallelisierung
  • Debug-Tools: Hervorragende Dev Experience

Tests:

// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test'

test('complete checkout flow', async ({ page }) => {
  await page.goto('/')
  await page.click('text=Makerspace Jahreskarte')
  await page.click('text=In den Warenkorb')
  await page.click('[aria-label="Warenkorb"]')
  await page.click('text=Zur Kasse')
  // ... weitere Schritte
})

13. Monitoring & Logging

Sentry (Optional)

Entscheidung: Sentry für Error Tracking

Begründung:

  • Real-time Errors: Sofortige Benachrichtigung
  • Stack Traces: Detaillierte Error-Informationen
  • User Context: Welcher User ist betroffen?
  • Release Tracking: Errors pro Version
  • Performance Monitoring: Transaction Tracing

Integration:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/sentry'],
  sentry: {
    dsn: process.env.SENTRY_DSN,
    environment: process.env.NODE_ENV,
  },
})

Alternative: Eigenes Logging-System mit Loki/Grafana


14. Email-Versand

Optionen

Option A: Eigener SMTP-Server

  • Pro: Volle Kontrolle, keine Kosten
  • Contra: Wartung, Spam-Probleme, Deliverability

Option B: Transactional Email Service

  • SendGrid: Zuverlässig, gute API
  • Postmark: Spezialisiert auf Transactional
  • Mailgun: Flexibel, gute Dokumentation
  • AWS SES: Günstig, aber mehr Setup

Empfehlung: Postmark oder SendGrid

  • Bessere Deliverability
  • Weniger Wartung
  • Templates & Analytics
  • Webhooks für Bounce/Spam

Nuxt Mailer:

// server/utils/mail.ts
import nodemailer from 'nodemailer'

export const sendOrderConfirmation = async (email: string, order: Order) => {
  const transporter = nodemailer.createTransporter({
    host: process.env.SMTP_HOST,
    port: 587,
    auth: {
      user: process.env.SMTP_USER,
      pass: process.env.SMTP_PASS,
    },
  })

  await transporter.sendMail({
    from: 'noreply@experimenta.science',
    to: email,
    subject: `Bestellbestätigung ${order.orderNumber}`,
    html: renderOrderEmail(order),
  })
}

15. Development Tools

ESLint + Prettier

Entscheidung: ESLint + Prettier für Code Quality

Config:

{
  "extends": ["@nuxt/eslint-config", "prettier"],
  "rules": {
    "vue/multi-word-component-names": "off"
  }
}

TypeScript

Entscheidung: Strict TypeScript

tsconfig.json:

{
  "extends": "./.nuxt/tsconfig.json",
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true
  }
}

16. Package Manager

pnpm

Entscheidung: pnpm statt npm/yarn

Begründung:

  • Schneller: Symlinks statt Kopien
  • Disk Space: Shared Dependencies
  • Strict: Verhindert Ghost Dependencies
  • Monorepo-Ready: Workspaces Support

17. Vollständige Dependency-Liste

Dependencies (package.json)

{
  "dependencies": {
    "nuxt": "^4.0.0",
    "vue": "^3.4.0",
    "@nuxtjs/tailwindcss": "^6.11.0",
    "radix-vue": "^1.9.0",
    "class-variance-authority": "^0.7.0",
    "clsx": "^2.1.0",
    "tailwind-merge": "^2.2.0",
    "drizzle-orm": "^0.29.0",
    "postgres": "^3.4.0",
    "zod": "^3.22.0",
    "vee-validate": "^4.12.0",
    "@vee-validate/zod": "^4.12.0",
    "pinia": "^2.1.0",
    "@pinia/nuxt": "^0.5.0",
    "jose": "^5.2.0",
    "@paypal/checkout-server-sdk": "^1.0.3",
    "nodemailer": "^6.9.0"
  },
  "devDependencies": {
    "@nuxt/eslint-config": "^0.2.0",
    "@nuxtjs/sentry": "^8.0.0",
    "drizzle-kit": "^0.20.0",
    "eslint": "^8.56.0",
    "prettier": "^3.2.0",
    "typescript": "^5.3.0",
    "vitest": "^1.2.0",
    "@vue/test-utils": "^2.4.0",
    "@playwright/test": "^1.41.0",
    "@nuxt/test-utils": "^3.11.0"
  }
}

18. Zusammenfassung

Kategorie Technologie Version
Framework Nuxt 4.x
UI Library shadcn-vue latest
Styling Tailwind CSS 4.x
Datenbank PostgreSQL 16+
ORM Drizzle 0.29+
Auth Cidaas -
Payment PayPal -
Validation Zod 3.x
Forms VeeValidate 4.x
State Pinia 2.x
Testing (Unit) Vitest 1.x
Testing (E2E) Playwright 1.x
Deployment Docker latest
CI/CD GitLab CI -
Hosting Hetzner -
Error Tracking Sentry (optional) 8.x
Email Postmark/SendGrid -

Ende des Dokuments