7702574904
- Added `PferdReiterEingabe` for horse and rider selection with search functionality and keyboard navigation. - Implemented `NennungenTabelle` to display filtered registrations based on selected horse or rider. - Introduced `NennungsMaske` to combine search, table, and competition views for streamlined user interaction. - Extended types with `Veranstalter` interface and mock data for better context and integration. - Documented ÖTO-compliant tournament structure for frontend reference. Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
26 KiB
26 KiB
Frontend-Dokumentation - Frontend Developer
🎯 Frontend-Übersicht
React 18+ basierte Single Page Application mit TypeScript, Material-UI und React Router.
Tech Stack:
- React 18+ (Function Components + Hooks)
- TypeScript 5+
- Material-UI v6 (Material Design 3)
- React Router v7 (Data Mode)
- Tailwind CSS v4
- Vite (Build Tool)
📁 Projekt-Struktur
/src
├── /app
│ ├── App.tsx # Main Component (RouterProvider)
│ ├── routes.tsx # React Router Configuration
│ │
│ └── /components
│ ├── Login.tsx # Login Page
│ ├── Dashboard.tsx # Admin Dashboard
│ │
│ ├── VeranstalterVerwaltung.tsx # Veranstalter Management
│ ├── VeranstalterAuswahl.tsx # Veranstalter Selection
│ │
│ ├── TurnierErstellen.tsx # Veranstaltung Overview
│ ├── TurnierAnsicht.tsx # Turnier View (8 Tabs)
│ │
│ ├── NennungsMaske.tsx # Registration Mask
│ ├── PferdReiterEingabe.tsx # Horse/Rider Input
│ ├── NennungenTabelle.tsx # Registrations Table
│ ├── VerkaufBuchungen.tsx # Sales/Bookings
│ ├── Bewerbsliste.tsx # Contest List
│ │
│ └── /turnier
│ ├── VeranstaltungUebersicht.tsx # Event Overview
│ ├── StammdatenTab.tsx # Master Data Tab
│ ├── OrganisationTab.tsx # Organization Tab
│ ├── BewerbeTab.tsx # Contests Tab
│ ├── ArtikelTab.tsx # Articles/Pricing Tab
│ ├── AbrechnungTab.tsx # Billing Tab
│ ├── NennungenTab.tsx # Registrations Tab
│ ├── StartlistenTab.tsx # Start Lists Tab
│ └── ErgebnislistenTab.tsx # Results Tab
│
├── /styles
│ ├── theme.css # Tailwind Theme + CSS Variables
│ └── fonts.css # Font Imports
│
└── main.tsx # Vite Entry Point
🔄 Component-Hierarchie
App
├── RouterProvider
├── Login (/)
│
├── Dashboard (/admin)
│ └── Veranstaltungs-Liste
│ └── Veranstaltungs-Karten
│
├── VeranstalterVerwaltung (/veranstalter)
│ └── Veranstalter-Tabelle
│ ├── Filter-Controls
│ └── CRUD-Buttons
│
├── TurnierErstellen (/veranstaltung/:id)
│ └── VeranstaltungUebersicht
│ └── Turnier-Karten
│ └── Status-Badge
│
└── TurnierAnsicht (/turnier/:veranstaltungId/:nr)
├── Navigation Breadcrumbs
├── Tab Navigation
└── Tab Content
├── StammdatenTab
│ ├── Turnier-Konfiguration
│ ├── Turnier-Beschreibung
│ └── Sponsoren
│
├── OrganisationTab
│ ├── Zeitplan
│ └── Kontakte
│
├── BewerbeTab
│ └── Bewerbs-Tabelle
│
├── ArtikelTab
│ ├── Nennungs-/Startgebühren
│ ├── Stallungen & Boxen
│ └── Zusatzleistungen
│
├── AbrechnungTab
│ ├── Buchungstabelle (70%)
│ │ ├── Haupt-Tabs
│ │ ├── Aktions-Buttons
│ │ └── Buchungs-Table
│ └── Aktionsbereich (30%)
│ ├── Teilnehmer-Auswahl
│ ├── Buchen-Panel
│ ├── Direkt Drucken
│ └── Zahlungsart
│
├── NennungenTab
│ └── NennungsMaske
│ ├── Obere Hälfte (50%)
│ │ ├── PferdReiterEingabe (60%)
│ │ └── VerkaufBuchungen (40%)
│ ├── Navigation Buttons (5%)
│ └── Untere Hälfte (45%)
│ ├── NennungenTabelle (60%)
│ └── Bewerbsliste (40%)
│
├── StartlistenTab
├── ErgebnislistenTab
🎨 Styling System
Material-UI Theme
// App.tsx
import { createTheme, ThemeProvider } from '@mui/material/styles';
const theme = createTheme({
palette: {
primary: {
main: '#3F51B5', // Indigo
light: '#7986CB',
dark: '#303F9F',
},
secondary: {
main: '#FF5722',
},
},
typography: {
fontSize: 11, // Kompakt für Desktop
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none', // Keine ALL CAPS
},
},
},
},
});
<ThemeProvider theme={theme}>
<RouterProvider router={router} />
</ThemeProvider>
Tailwind CSS v4
/* src/styles/theme.css */
@import "tailwindcss";
@theme {
/* Colors */
--color-primary: #3f51b5;
--color-primary-light: #7986cb;
--color-primary-dark: #303f9f;
/* Font Sizes (Kompakt) */
--font-size-xs: 10px;
--font-size-sm: 11px;
--font-size-base: 13px;
--font-size-lg: 15px;
/* Spacing */
--spacing-tight: 8px;
--spacing-normal: 16px;
--spacing-loose: 24px;
}
/* Base Styles */
body {
font-family: 'Roboto', sans-serif;
font-size: var(--font-size-base);
}
/* Material-UI Overrides */
.MuiTab-root {
font-size: 11px !important;
min-height: 36px !important;
}
.MuiTableCell-root {
font-size: 10px !important;
padding: 8px !important;
}
Styling Best Practices
1. MUI Components mit sx Prop
<Button
variant="contained"
sx={{
fontSize: '11px',
py: 0.5,
px: 2,
minWidth: 120
}}
>
Speichern
</Button>
2. Tailwind Utility Classes
<div className="flex items-center gap-2 p-4">
<span className="text-sm font-semibold">Status:</span>
<Badge className="bg-green-500">Aktiv</Badge>
</div>
3. Hybrid Approach (empfohlen)
<Box sx={{ p: 2, bgcolor: 'background.paper' }}>
<Typography className="text-xs font-medium">
Turnier-Name
</Typography>
</Box>
🧩 Component Patterns
1. Container Component (Smart)
// BewerbeTab.tsx
import { useState, useEffect } from 'react';
export function BewerbeTab() {
const [bewerbe, setBewerbe] = useState<Bewerb[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Daten laden
fetchBewerbe();
}, []);
const fetchBewerbe = async () => {
try {
setLoading(true);
const data = await api.getBewerbe();
setBewerbe(data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const handleCreate = (bewerb: Bewerb) => {
setBewerbe([...bewerbe, bewerb]);
};
if (loading) return <Loading />;
return (
<Box>
<BewerbeToolbar onCreate={handleCreate} />
<BewerbeTable bewerbe={bewerbe} />
</Box>
);
}
2. Presentational Component (Dumb)
// BewerbeTable.tsx
interface Props {
bewerbe: Bewerb[];
onEdit?: (bewerb: Bewerb) => void;
onDelete?: (id: number) => void;
}
export function BewerbeTable({ bewerbe, onEdit, onDelete }: Props) {
return (
<Table>
<TableHead>
<TableRow>
<TableCell>Nr.</TableCell>
<TableCell>Name</TableCell>
<TableCell>Klasse</TableCell>
</TableRow>
</TableHead>
<TableBody>
{bewerbe.map((bewerb) => (
<TableRow key={bewerb.id}>
<TableCell>{bewerb.nr}</TableCell>
<TableCell>{bewerb.name}</TableCell>
<TableCell>{bewerb.klasse}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
3. Custom Hook
// hooks/useTurnier.ts
import { useState, useEffect } from 'react';
export function useTurnier(id: string) {
const [turnier, setTurnier] = useState<Turnier | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchTurnier = async () => {
try {
setLoading(true);
const data = await api.getTurnier(id);
setTurnier(data);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};
fetchTurnier();
}, [id]);
return { turnier, loading, error };
}
// Usage
const { turnier, loading, error } = useTurnier('123');
🔄 State Management
Aktuell: Local State (useState)
// TurnierAnsicht.tsx
export function TurnierAnsicht() {
const [activeTab, setActiveTab] = useState(0);
const [turnier, setTurnier] = useState<Turnier | null>(null);
return (
<Box>
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}>
<Tab label="Stammdaten" />
<Tab label="Bewerbe" />
</Tabs>
{activeTab === 0 && <StammdatenTab turnier={turnier} />}
{activeTab === 1 && <BewerbeTab turnierId={turnier?.id} />}
</Box>
);
}
Empfohlen: React Context (für geteilten State)
// context/TurnierContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface TurnierContextType {
turnier: Turnier | null;
setTurnier: (turnier: Turnier) => void;
bewerbe: Bewerb[];
setBewerbe: (bewerbe: Bewerb[]) => void;
}
const TurnierContext = createContext<TurnierContextType | undefined>(undefined);
export function TurnierProvider({ children }: { children: ReactNode }) {
const [turnier, setTurnier] = useState<Turnier | null>(null);
const [bewerbe, setBewerbe] = useState<Bewerb[]>([]);
return (
<TurnierContext.Provider value={{ turnier, setTurnier, bewerbe, setBewerbe }}>
{children}
</TurnierContext.Provider>
);
}
export function useTurnierContext() {
const context = useContext(TurnierContext);
if (!context) {
throw new Error('useTurnierContext must be used within TurnierProvider');
}
return context;
}
// Usage in TurnierAnsicht.tsx
<TurnierProvider>
<TurnierAnsicht />
</TurnierProvider>
// Usage in Child Component
const { turnier, setTurnier } = useTurnierContext();
Alternative: Zustand (für komplexe Apps)
// store/useTurnierStore.ts
import { create } from 'zustand';
interface TurnierStore {
turnier: Turnier | null;
bewerbe: Bewerb[];
nennungen: Nennung[];
setTurnier: (turnier: Turnier) => void;
addBewerb: (bewerb: Bewerb) => void;
addNennung: (nennung: Nennung) => void;
clearStore: () => void;
}
export const useTurnierStore = create<TurnierStore>((set) => ({
turnier: null,
bewerbe: [],
nennungen: [],
setTurnier: (turnier) => set({ turnier }),
addBewerb: (bewerb) =>
set((state) => ({ bewerbe: [...state.bewerbe, bewerb] })),
addNennung: (nennung) =>
set((state) => ({ nennungen: [...state.nennungen, nennung] })),
clearStore: () => set({ turnier: null, bewerbe: [], nennungen: [] }),
}));
// Usage
const { turnier, addBewerb } = useTurnierStore();
🌐 Routing
Route Konfiguration
// routes.tsx
import { createBrowserRouter } from 'react-router';
import { Login } from './components/Login';
import { Dashboard } from './components/Dashboard';
import { VeranstalterVerwaltung } from './components/VeranstalterVerwaltung';
import { TurnierErstellen } from './components/TurnierErstellen';
import { TurnierAnsicht } from './components/TurnierAnsicht';
export const router = createBrowserRouter([
{
path: '/',
element: <Login />,
},
{
path: '/admin',
element: <Dashboard />,
},
{
path: '/veranstalter',
element: <VeranstalterVerwaltung />,
},
{
path: '/veranstaltung/:id',
element: <TurnierErstellen />,
},
{
path: '/turnier/:veranstaltungId/:nr',
element: <TurnierAnsicht />,
},
{
path: '*',
element: <NotFound />,
},
]);
Navigation
import { useNavigate, useParams } from 'react-router';
export function TurnierAnsicht() {
const navigate = useNavigate();
const params = useParams();
const veranstaltungId = params.veranstaltungId;
const turnierNr = params.nr;
const handleZurueck = () => {
navigate(`/veranstaltung/${veranstaltungId}`);
};
const handleToAdmin = () => {
navigate('/admin');
};
return (
<Box>
<Breadcrumbs>
<Link onClick={handleToAdmin}>Admin - Verwaltung</Link>
<Link onClick={handleZurueck}>Veranstaltung</Link>
<Typography>Turnier {turnierNr}</Typography>
</Breadcrumbs>
</Box>
);
}
📡 API Integration
API Client Setup
// api/client.ts
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
class ApiClient {
private token: string | null = null;
setToken(token: string) {
this.token = token;
localStorage.setItem('token', token);
}
clearToken() {
this.token = null;
localStorage.removeItem('token');
}
async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(this.token && { Authorization: `Bearer ${this.token}` }),
...options.headers,
};
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'Request failed');
}
return response.json();
}
get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'GET' });
}
post<T>(endpoint: string, data: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
}
put<T>(endpoint: string, data: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
});
}
delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'DELETE' });
}
}
export const apiClient = new ApiClient();
API Service Layer
// api/turnierService.ts
import { apiClient } from './client';
export interface Turnier {
id: number;
veranstaltungId: number;
nr: string;
name: string;
// ...
}
export const turnierService = {
async getTurniere(veranstaltungId?: number): Promise<Turnier[]> {
const params = veranstaltungId ? `?veranstaltungId=${veranstaltungId}` : '';
const response = await apiClient.get<{ data: Turnier[] }>(`/turniere${params}`);
return response.data;
},
async getTurnierById(id: number): Promise<Turnier> {
const response = await apiClient.get<{ data: Turnier }>(`/turniere/${id}`);
return response.data;
},
async createTurnier(turnier: Partial<Turnier>): Promise<Turnier> {
const response = await apiClient.post<{ data: Turnier }>('/turniere', turnier);
return response.data;
},
async updateTurnier(id: number, turnier: Partial<Turnier>): Promise<Turnier> {
const response = await apiClient.put<{ data: Turnier }>(`/turniere/${id}`, turnier);
return response.data;
},
async deleteTurnier(id: number): Promise<void> {
await apiClient.delete(`/turniere/${id}`);
},
};
React Query Integration (empfohlen)
// hooks/useTurniere.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { turnierService } from '../api/turnierService';
export function useTurniere(veranstaltungId?: number) {
return useQuery({
queryKey: ['turniere', veranstaltungId],
queryFn: () => turnierService.getTurniere(veranstaltungId),
});
}
export function useTurnier(id: number) {
return useQuery({
queryKey: ['turnier', id],
queryFn: () => turnierService.getTurnierById(id),
enabled: !!id,
});
}
export function useCreateTurnier() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: turnierService.createTurnier,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['turniere'] });
},
});
}
// Usage in Component
const { data: turniere, isLoading } = useTurniere(veranstaltungId);
const createMutation = useCreateTurnier();
const handleCreate = (turnier: Partial<Turnier>) => {
createMutation.mutate(turnier);
};
📝 Form Handling
React Hook Form
// components/TurnierForm.tsx
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const turnierSchema = z.object({
nr: z.string().min(1, 'Nummer ist erforderlich'),
name: z.string().min(1, 'Name ist erforderlich'),
znsDaten: z.string().optional(),
oetoTyp: z.enum(['national', 'international']),
});
type TurnierFormData = z.infer<typeof turnierSchema>;
export function TurnierForm({ onSubmit }: { onSubmit: (data: TurnierFormData) => void }) {
const {
control,
handleSubmit,
formState: { errors },
} = useForm<TurnierFormData>({
resolver: zodResolver(turnierSchema),
defaultValues: {
nr: '',
name: '',
oetoTyp: 'national',
},
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="nr"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Turnier-Nummer"
error={!!errors.nr}
helperText={errors.nr?.message}
size="small"
fullWidth
/>
)}
/>
<Controller
name="name"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Turnier-Name"
error={!!errors.name}
helperText={errors.name?.message}
size="small"
fullWidth
/>
)}
/>
<Controller
name="oetoTyp"
control={control}
render={({ field }) => (
<FormControl fullWidth size="small">
<InputLabel>ÖTO-Typ</InputLabel>
<Select {...field} label="ÖTO-Typ">
<MenuItem value="national">National</MenuItem>
<MenuItem value="international">International</MenuItem>
</Select>
</FormControl>
)}
/>
<Button type="submit" variant="contained">
Speichern
</Button>
</form>
);
}
🎯 TypeScript Types
Type Definitions
// types/index.ts
export interface Veranstalter {
id: number;
name: string;
adresse: string;
plz: string;
ort: string;
land: string;
telefon: string;
email: string;
website: string;
vereinsnummer: string;
}
export interface Veranstaltung {
id: number;
veranstalterId: number;
name: string;
ort: string;
startDatum: string;
endDatum: string;
status: 'geplant' | 'laufend' | 'abgeschlossen';
turniere: Turnier[];
}
export interface Turnier {
id: number;
veranstaltungId: number;
nr: string;
name: string;
znsDaten: string;
oetoTyp: 'national' | 'international';
feiTyp?: string;
titel: string;
subTitel: string;
sponsoren: Sponsor[];
}
export interface Sponsor {
name: string;
logo: string;
}
export interface Bewerb {
id: number;
turnierId: number;
nr: string;
name: string;
klasse: string;
tag: number;
datum: string;
beginn: string;
platz: string;
typ: string;
richter: string;
maxTeilnehmer: number;
startgebuehr: number;
}
export interface Reiter {
id: number;
vorname: string;
nachname: string;
geburtsdatum: string;
ort: string;
land: string;
verein: string;
lizenznummer: string;
}
export interface Pferd {
id: number;
name: string;
geschlecht: 'Hengst' | 'Stute' | 'Wallach';
geburtsjahr: number;
rasse: string;
farbe: string;
besitzer: string;
lebensnummer: string;
}
export interface Nennung {
id: number;
turnierId: number;
bewerbId: number;
reiterId: number;
pferdId: number;
startnummer?: number;
startwunsch?: 'vorne' | 'hinten';
status: 'offen' | 'bestätigt' | 'gestartet' | 'abgeschlossen';
}
export interface Buchung {
id: number;
turnierId: number;
reiterId?: number;
pferdId?: number;
buchungstext: string;
soll: number;
haben: number;
saldo: number;
zahlungsart: 'bar' | 'scheck' | 'bankomat' | 'kreditkarte';
status: 'offen' | 'bezahlt' | 'storniert';
}
🧪 Testing
Component Testing (React Testing Library)
// __tests__/BewerbeTab.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { BewerbeTab } from '../components/turnier/BewerbeTab';
describe('BewerbeTab', () => {
it('renders bewerbe table', () => {
render(<BewerbeTab />);
expect(screen.getByText('Bewerbs-Übersicht')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Neuer Bewerb/i })).toBeInTheDocument();
});
it('opens dialog when "Neuer Bewerb" is clicked', async () => {
render(<BewerbeTab />);
const button = screen.getByRole('button', { name: /Neuer Bewerb/i });
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText('Bewerb erstellen')).toBeInTheDocument();
});
});
});
Hook Testing
// __tests__/useTurnier.test.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { useTurnier } from '../hooks/useTurnier';
describe('useTurnier', () => {
it('fetches turnier data', async () => {
const { result } = renderHook(() => useTurnier('123'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.turnier).toBeDefined();
expect(result.current.error).toBeNull();
});
});
⚡ Performance Optimierung
1. Code Splitting (Lazy Loading)
// App.tsx
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./components/Dashboard'));
const TurnierAnsicht = lazy(() => import('./components/TurnierAnsicht'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<RouterProvider router={router} />
</Suspense>
);
}
2. Memoization
import { memo, useMemo, useCallback } from 'react';
// Component Memoization
export const BewerbeTable = memo(({ bewerbe }: { bewerbe: Bewerb[] }) => {
return <Table>...</Table>;
});
// Value Memoization
const sortedBewerbe = useMemo(() => {
return bewerbe.sort((a, b) => a.nr.localeCompare(b.nr));
}, [bewerbe]);
// Function Memoization
const handleDelete = useCallback((id: number) => {
deleteBewerb(id);
}, []);
3. Virtual Scrolling (für große Listen)
import { FixedSizeList } from 'react-window';
function BewerbeList({ bewerbe }: { bewerbe: Bewerb[] }) {
return (
<FixedSizeList
height={600}
itemCount={bewerbe.length}
itemSize={50}
width="100%"
>
{({ index, style }) => (
<div style={style}>
{bewerbe[index].name}
</div>
)}
</FixedSizeList>
);
}
🔨 Build & Development
Vite Configuration
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react-router'],
'mui-vendor': ['@mui/material', '@mui/icons-material'],
},
},
},
},
});
Package.json Scripts
{
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"lint": "eslint src --ext ts,tsx",
"type-check": "tsc --noEmit"
}
}
📦 Key Dependencies
{
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router": "^7.1.3",
"@mui/material": "^6.3.0",
"@mui/icons-material": "^6.3.0",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"tailwindcss": "^4.0.0",
"@tanstack/react-query": "^5.62.15",
"react-hook-form": "^7.55.0",
"zod": "^3.24.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"vitest": "^3.0.0",
"@testing-library/react": "^16.1.0",
"@testing-library/jest-dom": "^6.6.3",
"eslint": "^9.18.0"
}
}
🚀 Deployment
Environment Variables
# .env.development
VITE_API_BASE_URL=http://localhost:3000/api
# .env.production
VITE_API_BASE_URL=https://api.turnierverwaltung.at/api
Vercel Deployment
# Install Vercel CLI
npm i -g vercel
# Deploy
vercel --prod
Dokumentiert von: Frontend Developer
Version: 1.0
Datum: 2026-03-24