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
| Technologie | Version | Verwendung |
|---|---|---|
| Next.js | 15.4 | Framework (App Router) |
| React | 19 | Component Library |
| TypeScript | 5.x | Typsystem |
| Zustand | 5.x | State Management |
| Tailwind CSS | 3.x | Styling |
| 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(),
};
}
Legal Documents Provider
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
| Aspekt | Mobile (Ionic) | Web (Next.js) |
|---|---|---|
| Data Fetching | TanStack Query | Direkte API-Calls |
| State Persistence | Capacitor Preferences | localStorage |
| Routing | React Router v5 | Next.js App Router |
| Auth Check | useAuth Hook | AuthProvider + Redirect |
| Push Notifications | OneSignal + Capacitor | Nicht implementiert |
| Offline Support | Geplant | Nicht geplant |