Zum Hauptinhalt springen

Web App (Next.js)

Die Web App ist für Auftraggeber (Behörden, Schulen, Gesundheitswesen) konzipiert und ermöglicht die Erstellung und Verwaltung von Dolmetsch-Aufträgen.

Technologie-Stack

TechnologieVersionVerwendung
Next.js15.4Framework (App Router)
React19Component Library
TypeScript5.xTypsystem
Zustand5.xState Management
Tailwind CSS3.xStyling
shadcn/ui-UI-Komponenten

Projektstruktur

apps/web/
├── app/ # Next.js App Router
│ ├── layout.tsx # Root Layout mit Providers
│ ├── page.tsx # Home (Redirect)
│ ├── login/
│ │ └── page.tsx
│ ├── register/
│ │ └── page.tsx
│ ├── dashboard/
│ │ └── page.tsx
│ ├── orders/
│ │ ├── page.tsx # Auftragsübersicht
│ │ ├── new/
│ │ │ └── page.tsx # Neuer Auftrag
│ │ └── [id]/
│ │ └── page.tsx # Auftragsdetails
│ ├── payments/
│ │ └── page.tsx
│ ├── profile/
│ │ └── page.tsx
│ └── notifications/
│ └── page.tsx
├── components/ # React Komponenten
│ ├── auth-provider.tsx
│ ├── legal-documents-provider.tsx
│ ├── google-maps-provider.tsx
│ └── ui/ # shadcn/ui Komponenten
├── lib/
│ ├── api.ts # API Client
│ ├── auth.ts # Auth Service
│ └── stores/
│ └── user-store.ts # Zustand Store
└── next.config.mjs

Provider-Hierarchie

File: layout.tsx

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<body>
<AuthProvider>
<LegalDocumentsProvider>
<ThemeProvider attribute="class" defaultTheme="light" forcedTheme="light">
<GoogleMapsProvider apiKey={process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY!}>
{children}
<Toaster />
</GoogleMapsProvider>
</ThemeProvider>
</LegalDocumentsProvider>
</AuthProvider>
</body>
</html>
);
}

State Management

Zustand Store mit Persist

File: user-store.ts

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface UserState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
hasPendingLegalDocuments: boolean;
}

interface UserActions {
setUser: (user: User | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
clearUser: () => void;
updateUser: (updates: Partial<User>) => void;
setHasPendingLegalDocuments: (value: boolean) => void;
}

export const useUserStore = create<UserState & UserActions>()(
persist(
(set) => ({
// State
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
hasPendingLegalDocuments: false,

// Actions
setUser: (user) => set({
user,
isAuthenticated: !!user,
hasPendingLegalDocuments: user?.hasPendingLegalDocuments ?? false,
}),

setLoading: (loading) => set({ isLoading: loading }),

setError: (error) => set({ error }),

clearUser: () => set({
user: null,
isAuthenticated: false,
hasPendingLegalDocuments: false,
}),

updateUser: (updates) => set((state) => ({
user: state.user ? { ...state.user, ...updates } : null,
hasPendingLegalDocuments: updates.hasPendingLegalDocuments ?? state.hasPendingLegalDocuments,
})),

setHasPendingLegalDocuments: (value) => set({ hasPendingLegalDocuments: value }),
}),
{
name: 'hoffnung-user-storage',
partialize: (state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
}),
}
)
);

// Helper Hooks
export const useAuth = () => useUserStore((state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
isLoading: state.isLoading,
}));

export const useUserActions = () => useUserStore((state) => ({
setUser: state.setUser,
clearUser: state.clearUser,
updateUser: state.updateUser,
}));

Authentication

Auth Provider

File: auth-provider.tsx

'use client';

import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { authService } from '@/lib/auth';
import { useUserStore } from '@/lib/stores/user-store';

const PUBLIC_PATHS = ['/login', '/register', '/forgot-password', '/reset-password', '/rate-order'];

export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [isInitialized, setIsInitialized] = useState(false);
const { setLoading } = useUserStore();

useEffect(() => {
async function initAuth() {
setLoading(true);

try {
await authService.initializeAuth();
} catch (error) {
// Token ungültig oder abgelaufen
if (!PUBLIC_PATHS.includes(pathname)) {
const returnUrl = encodeURIComponent(pathname);
router.push(`/login?returnUrl=${returnUrl}`);
}
} finally {
setLoading(false);
setIsInitialized(true);
}
}

initAuth();
}, [pathname, router, setLoading]);

if (!isInitialized) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}

return <>{children}</>;
}

Auth Service

File: auth.ts

import { apiClient } from './api';
import { useUserStore } from './stores/user-store';

export const authService = {
async login(email: string, password: string) {
const response = await apiClient.login({ email, password });
useUserStore.getState().setUser(response.user);
return response;
},

async register(data: RegisterData) {
const response = await apiClient.register(data);
// Nicht automatisch einloggen nach Registrierung
// (Benutzer muss zuerst aktiviert werden)
return response;
},

async logout() {
try {
await apiClient.logout();
} finally {
useUserStore.getState().clearUser();
}
},

async loadProfile() {
if (!apiClient.isAuthenticated()) {
return null;
}

try {
const user = await apiClient.getProfile();
useUserStore.getState().setUser(user);
return user;
} catch (error) {
useUserStore.getState().clearUser();
throw error;
}
},

async initializeAuth() {
const cachedUser = useUserStore.getState().user;

if (cachedUser && apiClient.isAuthenticated()) {
// Cached User verwenden, im Hintergrund aktualisieren
apiClient.getProfile().then(user => {
useUserStore.getState().setUser(user);
}).catch(() => {
// Token ungültig
useUserStore.getState().clearUser();
});
return cachedUser;
}

if (apiClient.isAuthenticated()) {
return await this.loadProfile();
}

return null;
},

async updateProfile(data: Partial<User>) {
const user = await apiClient.updateProfile(data);
useUserStore.getState().setUser(user);
return user;
},
};

API Client

File: api.ts

const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';

class ApiClient {
private token: string | null = null;

constructor() {
if (typeof window !== 'undefined') {
this.token = localStorage.getItem('auth_token');
}
}

private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${BASE_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
...(this.token && { 'Authorization': `Bearer ${this.token}` }),
...options.headers,
},
});

if (response.status === 401) {
this.handleUnauthorized();
throw new Error('Unauthorized');
}

if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `Request failed: ${response.status}`);
}

return response.json();
}

private handleUnauthorized() {
this.clearToken();
useUserStore.getState().clearUser();

// returnUrl berechnen (ohne Verschachtelung)
const currentPath = window.location.pathname;
const searchParams = new URLSearchParams(window.location.search);
const existingReturnUrl = searchParams.get('returnUrl');

if (!existingReturnUrl && currentPath !== '/login') {
const returnUrl = encodeURIComponent(currentPath + window.location.search);
window.location.href = `/login?returnUrl=${returnUrl}`;
} else if (!existingReturnUrl) {
window.location.href = '/login';
}
}

setToken(token: string) {
this.token = token;
localStorage.setItem('auth_token', token);
}

clearToken() {
this.token = null;
localStorage.removeItem('auth_token');
}

isAuthenticated(): boolean {
return !!this.token;
}

// Auth
async login(data: LoginData): Promise<AuthResponse> {
const response = await this.request<AuthResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify(data),
});
this.setToken(response.token);
return response;
}

async logout(): Promise<void> {
await this.request('/auth/logout', { method: 'POST' });
this.clearToken();
}

async getProfile(): Promise<User> {
return this.request('/auth/profile');
}

// Orders
async getOrders(filters?: OrderFilters): Promise<PaginatedResponse<Order>> {
const params = new URLSearchParams();
if (filters?.status) params.append('status', filters.status);
if (filters?.page) params.append('page', String(filters.page));

return this.request(`/orders?${params}`);
}

async createOrder(data: CreateOrderData): Promise<Order> {
return this.request('/orders', {
method: 'POST',
body: JSON.stringify(data),
});
}

async cancelOrder(id: string): Promise<Order> {
return this.request(`/orders/${id}/cancel`, { method: 'PATCH' });
}

// Invoices
async getInvoicesForUser(): Promise<Invoice[]> {
return this.request('/invoices');
}

async instructInvoicePayment(id: string): Promise<Invoice> {
return this.request(`/invoices/${id}/instruct-payment`, { method: 'PATCH' });
}

// File Download
async downloadFile(url: string, filename: string): Promise<void> {
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${this.token}`,
},
});

const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
}
}

export const apiClient = new ApiClient();

// Convenience Exports
export const auth = {
login: (data: LoginData) => apiClient.login(data),
logout: () => apiClient.logout(),
getProfile: () => apiClient.getProfile(),
};

export const orders = {
list: (filters?: OrderFilters) => apiClient.getOrders(filters),
create: (data: CreateOrderData) => apiClient.createOrder(data),
cancel: (id: string) => apiClient.cancelOrder(id),
};

Google Maps Integration

File: google-maps-provider.tsx

'use client';

import { LoadScript, Libraries } from '@react-google-maps/api';

const libraries: Libraries = ['places'];

interface GoogleMapsProviderProps {
apiKey: string;
children: React.ReactNode;
}

export function GoogleMapsProvider({ apiKey, children }: GoogleMapsProviderProps) {
return (
<LoadScript googleMapsApiKey={apiKey} libraries={libraries}>
{children}
</LoadScript>
);
}

Address Autocomplete Component

'use client';

import { Autocomplete } from '@react-google-maps/api';
import { useState, useRef } from 'react';

interface AddressAutocompleteProps {
onPlaceSelected: (address: ParsedAddress) => void;
}

export function AddressAutocomplete({ onPlaceSelected }: AddressAutocompleteProps) {
const [autocomplete, setAutocomplete] = useState<google.maps.places.Autocomplete | null>(null);
const inputRef = useRef<HTMLInputElement>(null);

const handlePlaceChanged = () => {
if (!autocomplete) return;

const place = autocomplete.getPlace();
if (!place.address_components) return;

const parsed = parseGooglePlace(place);
onPlaceSelected(parsed);
};

return (
<Autocomplete
onLoad={setAutocomplete}
onPlaceChanged={handlePlaceChanged}
options={{
componentRestrictions: { country: 'de' },
types: ['address'],
}}
>
<input
ref={inputRef}
type="text"
placeholder="Adresse eingeben..."
className="w-full px-3 py-2 border rounded-md"
/>
</Autocomplete>
);
}

function parseGooglePlace(place: google.maps.places.PlaceResult): ParsedAddress {
const components = place.address_components || [];

const get = (type: string) =>
components.find(c => c.types.includes(type))?.long_name || '';

return {
street: get('route'),
houseNumber: get('street_number'),
postalCode: get('postal_code'),
city: get('locality') || get('administrative_area_level_2'),
latitude: place.geometry?.location?.lat(),
longitude: place.geometry?.location?.lng(),
};
}

File: legal-documents-provider.tsx

'use client';

import { useEffect, useState } from 'react';
import { useUserStore } from '@/lib/stores/user-store';
import { apiClient } from '@/lib/api';
import { LegalDocumentModal } from './legal-document-modal';

export function LegalDocumentsProvider({ children }: { children: React.ReactNode }) {
const { isAuthenticated, hasPendingLegalDocuments, setHasPendingLegalDocuments } = useUserStore();
const [pendingDocuments, setPendingDocuments] = useState<LegalDocument[]>([]);
const [showModal, setShowModal] = useState(false);

useEffect(() => {
async function checkDocuments() {
if (!isAuthenticated) return;

try {
const documents = await apiClient.getPendingLegalDocuments();
setPendingDocuments(documents);
setHasPendingLegalDocuments(documents.length > 0);
setShowModal(documents.length > 0);
} catch (error) {
console.error('Failed to check legal documents:', error);
}
}

checkDocuments();
}, [isAuthenticated, setHasPendingLegalDocuments]);

const handleAccept = async (documentIds: string[]) => {
await apiClient.acceptLegalDocuments(documentIds);
setPendingDocuments([]);
setHasPendingLegalDocuments(false);
setShowModal(false);
};

return (
<>
{children}
{showModal && (
<LegalDocumentModal
documents={pendingDocuments}
onAccept={handleAccept}
/>
)}
</>
);
}

Build & Deployment

# Entwicklung
npm run dev

# Build
npm run build

# Type-Check
npm run type-check

# Lint
npm run lint

# Start Production
npm run start

Next.js Konfiguration

File: next.config.mjs

/** @type {import('next').NextConfig} */
const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
images: {
unoptimized: true,
},
};

export default nextConfig;

Architektur-Unterschiede zur Mobile App

AspektMobile (Ionic)Web (Next.js)
Data FetchingTanStack QueryDirekte API-Calls
State PersistenceCapacitor PreferenceslocalStorage
RoutingReact Router v5Next.js App Router
Auth CheckuseAuth HookAuthProvider + Redirect
Push NotificationsOneSignal + CapacitorNicht implementiert
Offline SupportGeplantNicht geplant