Wizper ist eine moderne, sprachbasierte Dating-Plattform, die authentische Verbindungen durch Voice-First-Kommunikation ermöglicht.
- Architektur-Überblick
- Projektstruktur
- Technologie-Stack
- Komplexe Systeme im Detail
- Lokales Setup
- Umgebungsvariablen
- Deployment
- Troubleshooting
- Welcome → Location → Firstname → Birthday → Profile: Gender → Interested In → Orientation → Looking For → Passions → Age Verification → Voice → Subscription (weekly/monthly)
- Setup Intent: Customer erstellen +
client_secret - Payment Method bestätigen (Card)
- Payment Method an Customer anhängen, als Default setzen
- Subscription erstellen mit passendem
priceId(weekly/monthly)
- POST
/api/create-setup-intent- Erstellt Stripe Customer + SetupIntent
- Response:
clientSecret,customerId
- POST
/api/create-subscription- Body:
{ customerId, paymentMethodId, priceId, email?, name? } - Erstellt Subscription mit übergebenem
priceId
- Body:
- POST
/api/cancel-subscription- Body:
{ userId }
- Body:
- POST
/api/reactivate-subscription- Body:
{ userId }
- Body:
- POST
/api/create-checkout-session- Body:
{ userId, priceId } - Response:
{ sessionId }
- Body:
- POST
/api/get-user-interests- Body:
{ userIds: string[] } - Response:
{ items: { user_id, interest_id }[] }
- Body:
Hinweise:
- JSON senden, kein GET für
/api/get-user-interests(GET antwortet mit "Cannot GET"). - CORS erlaubt:
https://wizper.cloud,https://www.wizper.cloud,http://localhost:5173,http://localhost:3000.
Das Projekt ist als Monorepo aufgebaut und besteht aus mehreren Hauptkomponenten:
┌─────────────────────────────────────────────────────────────┐
│ Wizper Platform │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Frontend │ │ API Server │ │ Signaling │ │
│ │ React + TS │◄─►│ Express │◄─►│ Server │ │
│ │ Capacitor │ │ Node.js │ │ WebSocket │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ External Services & Infrastructure │ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ • Supabase (Database, Auth, Storage) │ │
│ │ • Stripe (Payments, Age Verification) │ │
│ │ • Eigenes WebRTC (Signaling Server + TURN/Coturn) │ │
│ │ • Nominatim (Reverse Geocoding) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
- Framework: React 19 + TypeScript + Vite
- Mobile: Capacitor für iOS & Android
- State Management: Zustand
- Styling: Styled-components
- Routing: React Router v7
- Framework: Express.js + TypeScript
- Hauptaufgaben:
- Stripe Payment Processing
- Age Verification
- Subscription Management
- Webhook Handling
- Framework: Node.js + Socket.io
- Zweck: WebRTC Signaling für Voice Calls
- PostgreSQL Database
- Authentication (Email, OAuth)
- Row Level Security (RLS)
- Storage (Voice samples, Profile pictures)
- Realtime (Presence, Messages)
wizper/
├── src/ # Frontend Source Code
│ ├── app/ # App-Level (Router, Providers)
│ ├── features/ # Feature-based Organization
│ │ ├── auth/ # Authentication (Login, Register, OAuth)
│ │ ├── onboarding/ # Multi-Step Onboarding
│ │ │ ├── components/
│ │ │ │ ├── steps/ # Individual Onboarding Steps
│ │ │ │ ├── AgeVerificationStep.tsx
│ │ │ │ └── SubscriptionStep.tsx
│ │ │ └── pages/
│ │ │ └── OnboardingPage.tsx
│ │ ├── calling/ # Voice Calling System
│ │ │ ├── components/ # Call UI Components
│ │ │ └── services/ # CallService, RingtoneService
│ │ ├── discover/ # User Discovery
│ │ ├── profile/ # User Profile Management
│ │ └── admin/ # Admin Dashboard
│ ├── shared/ # Shared Resources
│ │ ├── components/ # Reusable UI Components
│ │ ├── lib/ # Utilities (Supabase client)
│ │ ├── stores/ # Zustand Stores (auth, call, presence)
│ │ └── styles/ # Global Styles & Theme
│ └── types/ # TypeScript Type Definitions
│
├── api/ # Backend API Server
│ └── src/
│ └── index.ts # Express Server with Stripe Integration
│
├── backend/
│ └── signaling/ # WebRTC Signaling Server
│ └── src/
│ └── index.ts # Socket.io Server
│
├── supabase/
│ └── migrations/ # Database Migrations
│ ├── 001_initial_schema.sql
│ ├── 002_add_profile_fields.sql
│ ├── 004_add_subscription_fields.sql
│ └── 008_update_intention_enum.sql
│
├── ios/ # iOS Capacitor App
│ └── App/
│ └── App/
│ └── Info.plist # iOS Permissions & Config
│
├── android/ # Android Capacitor App
│ └── app/
│ └── src/
│ └── main/
│ └── AndroidManifest.xml # Android Permissions
│
└── docker-compose.yml # Local Development Setup
- React 19.2 - UI Library
- TypeScript 5.9 - Type Safety
- Vite 7 - Build Tool & Dev Server
- Styled Components 6 - CSS-in-JS
- React Router 7 - Routing
- Zustand 5 - State Management
- Capacitor 8 - Native Mobile Wrapper
- Stripe React - Payment UI Components
- Native WebRTC - Voice Calls (CallService + Signaling + TURN)
- Material-UI - UI Components (Age Verification)
- Node.js 22 - Runtime
- Express - Web Framework
- Stripe Node SDK - Payment Processing
- Supabase JS Client - Database & Auth
- Supabase - BaaS (PostgreSQL, Auth, Storage, Realtime)
- Stripe - Payment & Age Verification
- Eigener TURN (Coturn) - NAT Traversal für WebRTC-Anrufe
- OpenStreetMap Nominatim - Reverse Geocoding
- Railway/Fly.io - Deployment Platform
Das Auth-System basiert auf Supabase Auth und verwendet einen zentralen ProtectedRoute Component für Route Guards.
┌─────────────────────────────────────────────────────────────┐
│ Auth & Route Protection │
├─────────────────────────────────────────────────────────────┤
│ │
│ User besucht Route │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Router │ │
│ │ (router.tsx)│ │
│ └──────┬───────┘ │
│ │ │
│ │ Route geschützt? │
│ ├───────────────┬─────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ Public Route ProtectedRoute ProtectedRoute │
│ (Landing, (Onboarding) (Discover, Profile) │
│ Login, requireOnboarding=true │
│ Register) │
│ │ │ │ │
│ │ ▼ ▼ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │
│ │ │ Auth Check: │ │ Auth Check: │ │
│ │ │ User logged │ │ User logged │ │
│ │ │ in? │ │ in + Onboard. │ │
│ │ └────┬────────────┘ │ complete? │ │
│ │ │ └────┬────────────┘ │
│ │ ├─ Yes → Render │ │
│ │ │ ├─ Yes → Render │
│ │ └─ No → /login └─ No → Redirect │
│ │ │
│ ▼ │
│ Component Rendered │
│ │
└─────────────────────────────────────────────────────────────┘
Öffentliche Routen (Jeder kann zugreifen):
/- Landing Page (Register)/login- Login Page/register- Register Page/verify- Email Verification/auth/callback- OAuth Callback
Geschützte Routen (Login erforderlich):
/onboarding- Onboarding Flow (nur für eingeloggte User)
Voll geschützte Routen (Login + Onboarding abgeschlossen):
/discover- User Discovery & Calling/profile- User Profile Management/admin- Admin Dashboard (zusätzlich: is_admin Flag)
Location: src/app/ProtectedRoute.tsx
interface ProtectedRouteProps {
children: React.ReactNode;
requireOnboarding?: boolean; // Default: false
}
// Verwendung im Router:
<Route
path="/onboarding"
element={
<ProtectedRoute>
<OnboardingPage />
</ProtectedRoute>
}
/>
<Route
path="/discover"
element={
<ProtectedRoute requireOnboarding={true}>
<DiscoverPage />
</ProtectedRoute>
}
/>Funktionsweise:
-
Prüft Auth-Status:
isLoading→ Zeigt Loading Screen!user→ Redirect zu/login
-
Prüft Onboarding (wenn
requireOnboarding=true):profile === null→ Wartet auf Profile-Load!profile.onboarding_complete→ Redirect zu/onboarding
-
Rendert Component:
- Wenn alle Checks erfolgreich → Children werden gerendert
State:
interface AuthState {
user: User | null; // Supabase User Object
session: Session | null; // Supabase Session
profile: UserProfile | null; // Custom User Profile aus DB
isLoading: boolean; // Auth-Initialisierung läuft
error: string | null; // Auth-Fehler
}Actions:
- initialize() // Initialisiert Auth, lädt Session & Profile
- login(email, password) // Email/Password Login
- register(email, password) // Email/Password Registrierung
- loginWithGoogle() // Google OAuth
- loginWithApple() // Apple OAuth
- logout() // Logout & Cleanup
- fetchProfile() // Lädt User Profile aus DB1. Neuer User (Registrierung):
1. User besucht Landing Page (/)
→ Redirects zu /login (wenn nicht eingeloggt)
2. User geht zu /register
→ Gibt Email + Password ein
→ register() wird aufgerufen
3. Supabase erstellt Auth User
→ session wird gesetzt
→ user wird gesetzt
→ profile ist null (noch nicht erstellt)
4. Redirect zu /onboarding
→ ProtectedRoute prüft: user ✓, profile.onboarding_complete ✗
→ Onboarding wird gerendert
5. User durchläuft Onboarding
→ Profile wird in DB erstellt
→ onboarding_complete = true
6. Redirect zu /discover
→ ProtectedRoute prüft: user ✓, onboarding_complete ✓
→ Discover Page wird gerendert
2. Bestehender User (Login):
1. User besucht Landing Page (/)
→ Redirects zu /login
2. User gibt Email + Password ein
→ login() wird aufgerufen
3. Supabase verifiziert Credentials
→ session wird gesetzt
→ user wird gesetzt
→ fetchProfile() lädt profile aus DB
4. Redirect basierend auf profile.onboarding_complete:
a) Onboarding nicht abgeschlossen:
→ Redirect zu /onboarding
b) Onboarding abgeschlossen:
→ Redirect zu /discover
3. OAuth Login (Google/Apple):
1. User klickt "Sign in with Google"
→ loginWithGoogle() wird aufgerufen
2. Supabase initiiert OAuth Flow
→ Redirect zu Google Login
→ User authentifiziert sich
3. Google redirected zu /auth/callback
→ Supabase parsed Token aus URL
→ session + user werden gesetzt
4. AuthCallbackPage prüft Status:
→ Lädt profile aus DB
→ Redirect zu /onboarding oder /discover
1. Auth Initialization (providers.tsx):
function AuthInitializer({ children }) {
const initialize = useAuthStore((state) => state.initialize);
useEffect(() => {
initialize(); // Lädt Session & Profile beim App-Start
}, [initialize]);
return <>{children}</>;
}2. Auth State Listener:
// In authStore.ts - initialize()
supabase.auth.onAuthStateChange(async (event, session) => {
if (event === 'SIGNED_OUT') {
set({ session: null, user: null, profile: null });
return;
}
set({ session, user: session?.user ?? null });
if (session?.user) {
await fetchProfile(); // Lädt Profile nach Login
}
});3. Profile-Check für Onboarding:
// In ProtectedRoute.tsx
if (requireOnboarding) {
if (profile === null) {
return; // Wartet auf Profile-Load
}
if (!profile.onboarding_complete) {
navigate('/onboarding', { replace: true });
return;
}
}1. Row Level Security (RLS) in Supabase:
Alle Tabellen haben RLS Policies:
-- users Tabelle: User kann nur eigenes Profil bearbeiten
CREATE POLICY "Users can update their own profile"
ON users FOR UPDATE
TO authenticated
USING (auth.uid() = id)
WITH CHECK (auth.uid() = id);
-- voice_samples: User kann nur eigene Samples hochladen
CREATE POLICY "Users can upload their own voice samples"
ON voice_samples FOR INSERT
TO authenticated
WITH CHECK (user_id = auth.uid());2. Service Role Key nur im Backend:
// ❌ NIEMALS im Frontend:
const supabase = createClient(url, SERVICE_ROLE_KEY);
// ✅ Nur ANON_KEY im Frontend:
const supabase = createClient(url, ANON_KEY);3. Token-Storage:
- Tokens werden automatisch in
localStoragegespeichert - Key:
sb-<project-ref>-auth-token - Beim Logout werden alle Auth-Tokens gelöscht
"User not logged in" trotz Login:
- ✅ Check: Browser localStorage löschen
- ✅ Check: Supabase Session expired? (7 Tage default)
- ✅ Check: Network Tab: Requests zu Supabase erfolgreich?
Onboarding Loop (User kommt nicht zu /discover):
- ✅ Check:
profile.onboarding_completein DB = true? - ✅ Check: fetchProfile() wird nach Onboarding aufgerufen?
- ✅ Check: ProtectedRoute erhält aktualisiertes profile?
ProtectedRoute rendert nicht:
- ✅ Check:
isLoadingbleibt true? → Auth-Init hängt - ✅ Check: Browser Console für Errors
- ✅ Check: Supabase URL & Keys korrekt?
OAuth Callback Error:
- ✅ Check: Redirect URL in Supabase Dashboard konfiguriert?
- ✅ Check:
${window.location.origin}/auth/callback - ✅ Check: Mobile: Capacitor Deep Link konfiguriert?
Das Payment System basiert auf Stripe und bietet zwei Subscription-Modelle:
┌─────────────────────────────────────────────────────────────┐
│ Payment Flow │
├─────────────────────────────────────────────────────────────┤
│ │
│ Age Verification (Setup Intent) │
│ ┌──────┐ 1. Request ┌──────────┐ 2. Create ┌──────┐ │
│ │ User │─────────────────►│ Backend │────────────►│Stripe│ │
│ │ │◄─────────────────│ API │◄────────────│ │ │
│ └──────┘ 3. Client └──────────┘ Customer+ └──────┘ │
│ Secret Setup Intent │
│ │ │
│ │ 4. Confirm Card (Browser) │
│ ▼ │
│ ┌──────┐ │
│ │Stripe│ 5. Payment Method Created │
│ │ JS │ │
│ └──────┘ │
│ │ │
│ │ 6. Save to Supabase (stripe_customer_id, pm_id) │
│ ▼ │
│ ┌──────────┐ │
│ │ Supabase │ users.stripe_customer_id = cus_xxx │
│ │ DB │ │
│ └──────────┘ │
│ │
│ Subscription (Payment Intent) │
│ ┌──────┐ 7. Subscribe ┌──────────┐ 8. Create ┌──────┐│
│ │ User │─────────────────►│ Backend │────────────►│Stripe││
│ │ │ + PM ID + Cust │ API │ Subscription│ ││
│ │ │ ID │ │ │ ││
│ │ │◄─────────────────│ │◄────────────│ ││
│ └──────┘ 9. Success └──────────┘ Sub Created└──────┘│
│ │ │ │
│ │ 10. Update Supabase │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Navigate │ │ Webhook │ 11. Update Status │
│ │ to │ │ Handler │ │
│ │ Discover │ └──────────┘ │
│ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
-
Free Tier (Default)
- Männer: 3 Telefonate & 3 Voice Messages pro Tag
- Frauen: Unbegrenzte Telefonate & Voice Messages
- Keine Super-Likes
- Werbung wird angezeigt
- Kein Invisible Mode
- Kein Entfernungsfilter (< 50km)
-
Premium Weekly (
price_1Qk4K4L9v5z4q2x8Z1j5b3n7)- Preis: 0,99€ / Woche
- Unbegrenzte Telefonate & Voice Messages
- Invisible Mode
- Priorisierte Matching-Algorithmen
- Entfernungsfilter (< 50km)
- Sehen wer dich geliked hat
- Keine Werbung
- 3 Super-Likes / Tag
-
Premium Monthly (
price_1Qk4K4L9v5z4q2x8Z1j5b3n8)- Preis: 2,99€ / Monat (25% günstiger als wöchentlich)
- Alle Features des Weekly Plans
- Maximale Ersparnis
POST /api/create-setup-intent
- Zweck: Erstellt einen Stripe Customer und Setup Intent für Age Verification
- Request Body: Leer
- Response:
{ "clientSecret": "seti_xxx_secret_xxx", "customerId": "cus_xxx" } - Flow:
- Erstellt neuen Stripe Customer
- Erstellt Setup Intent für Karte ohne Zahlung
- Gibt Client Secret zurück
POST /api/create-subscription
- Zweck: Erstellt eine Stripe Subscription
- Request Body:
{ "customerId": "cus_xxx", "paymentMethodId": "pm_xxx", "priceId": "price_xxx", "email": "user@example.com", "userId": "uuid" } - Response:
{ "subscription": { /* Stripe Subscription Object */ }, "customerId": "cus_xxx" } - Flow:
- Attached Payment Method an Customer
- Setzt Payment Method als Default
- Erstellt Subscription
- Returned Subscription Object
POST /api/cancel-subscription
- Zweck: Kündigt eine Subscription (läuft bis Ende der Periode)
- Request Body:
{ "userId": "uuid" } - Flow:
- Lädt User aus Supabase
- Findet aktive Subscription via
stripe_customer_id - Setzt
cancel_at_period_end: true - Updated Supabase
subscription_status: 'canceling'
POST /api/reactivate-subscription
- Zweck: Reaktiviert eine gekündigte Subscription
- Request Body:
{ "userId": "uuid" }
-
Customer ID Management:
- Customer ID wird bei Age Verification erstellt
- Wird sofort in Supabase
users.stripe_customer_idgespeichert - Wird vom Frontend an Subscription Endpoint übergeben
-
Payment Method Storage:
- Payment Method ID wird nach Age Verification im Frontend State gespeichert
- Wird für Subscription verwendet (kein erneutes Kartenformular)
- Wird an Stripe Customer attached
-
State Flow:
// OnboardingPage.tsx const [savedPaymentMethodId, setSavedPaymentMethodId] = useState<string | null>(null); const [savedCustomerId, setSavedCustomerId] = useState<string | null>(null); // Nach Age Verification setSavedPaymentMethodId(pmId); setSavedCustomerId(custId); await supabase.from('users').update({ stripe_customer_id: custId }); // Beim Subscription Step <SubscriptionStep initialPaymentMethodId={savedPaymentMethodId} initialCustomerId={savedCustomerId} />
Age Verification verwendet Stripe Setup Intents um sicherzustellen, dass User 18+ sind.
- Kreditkarten können nur von 18+ Personen besessen werden
- Keine Zahlung erforderlich (Setup Intent)
- Sichere PCI-DSS-konforme Verifizierung
- Payment Method kann sofort für Subscription verwendet werden
AgeVerificationStep.tsx
// 1. Fetch Setup Intent
useEffect(() => {
const response = await fetch('/api/create-setup-intent', {
method: 'POST'
});
const { clientSecret, customerId } = await response.json();
setClientSecret(clientSecret);
setCustomerId(customerId);
}, []);
// 2. Confirm Card Setup (ohne Zahlung)
const handleSubmit = async (event) => {
const result = await stripe.confirmCardSetup(clientSecret, {
payment_method: {
card: cardElement,
},
});
if (result.error) {
// Fehler (z.B. ungültige Karte)
setError(result.error.message);
} else {
// Erfolg - Payment Method erstellt
const pmId = result.setupIntent.payment_method;
onVerified(pmId, customerId); // Weiter zu nächstem Step
}
};- Kein Betrag wird abgebucht - Es ist nur eine Verifizierung
- Payment Method wird für spätere Subscription gespeichert
- Customer ID wird in Supabase gespeichert für zukünftige Transaktionen
Das Calling System nutzt natives WebRTC (Peer-to-Peer) mit eigenem Signaling-Server (WebSocket) und TURN-Server (Coturn) für NAT-Traversal.
┌─────────────────────────────────────────────────────────────┐
│ WebRTC Call Flow │
├─────────────────────────────────────────────────────────────┤
│ ┌────────┐ ┌────────┐ │
│ │ User A │ │ User B │ │
│ └───┬────┘ └───┬────┘ │
│ │ 1. Offer (SDP) │ │
│ ├────────────────────► Signaling (WebSocket) ─┼────────►│
│ │ │ 2. Forward Offer │ │
│ │ │ 3. Answer (SDP) │ │
│ │◄────────────────────┼──────────────────────┤ │
│ │ 4. ICE Candidates │ (optional: TURN) │ │
│ │◄────────────────────┴─────────────────────►│ │
│ │ 5. Peer-to-Peer Audio (oder via TURN) │ │
└─────────────────────────────────────────────────────────────┘
Zweck: Vermittelt Call-Signale zwischen Usern (Klingeln, Annehmen, Ablehnen)
// Socket.io Events
socket.on('call:signal', (data) => {
// Signal an anderen User weiterleiten
io.to(data.to).emit('call:incoming', {
from: socket.userId,
roomName: data.roomName,
fromUser: data.fromUser
});
});
socket.on('call:accept', (data) => {
// Akzeptanz an Caller signalisieren
io.to(data.callerId).emit('call:accepted', {
roomName: data.roomName
});
});
socket.on('call:reject', (data) => {
// Ablehnung signalisieren
io.to(data.callerId).emit('call:rejected');
});Zweck: Native WebRTC (RTCPeerConnection), Signaling über WebSocket, TURN (Coturn) für NAT-Traversal. Verwaltet Offer/Answer, ICE-Kandidaten und Audio-Streams.
Zweck: Zentraler State für Call Management
interface CallStore {
// State
isInCall: boolean;
incomingCall: IncomingCall | null;
currentCall: CallInfo | null;
// Actions
initiateCall: (toUserId: string, toUser: CallUser) => Promise<void>;
acceptCall: () => Promise<void>;
rejectCall: () => void;
endCall: () => void;
}User A ruft User B an:
-
User A klickt "Call"
await callStore.initiateCall(userB.id, userBData);
-
Signal wird über Signaling Server geschickt
socket.emit('call:signal', { to: userB.id, roomName: 'room_xxx', fromUser: userAData });
-
User B erhält Signal
socket.on('call:incoming', (data) => { callStore.setIncomingCall({ callerId: data.from, roomName: data.roomName, fromUser: data.fromUser }); // Ringtone abspielen ringtoneService.playIncoming(); });
-
User B akzeptiert
await callStore.acceptCall();
-
Beide User bauen WebRTC-Verbindung auf
- CallService sendet Answer, ICE-Kandidaten über Signaling
- Bei Bedarf: TURN (Coturn) für NAT-Traversal
-
Audio Stream läuft
- Peer-to-Peer oder via TURN-Relay
-
Call beenden
await callStore.endCall(); // - PeerConnection schließen, Signal an anderen User, Cleanup
Warum TURN Server?
- NAT Traversal (wenn Peer-to-Peer nicht möglich)
- Firewall Bypass
- Guaranteed Connectivity
Eigener TURN Server (Coturn)
Vollständige Anleitung für Coolify + wizper.cloud: siehe backend/DEPLOY-TURN.md (Checkliste, EXTERNAL_IP, TURN_SECRET, use-auth-secret).
Konfigurationsdatei: backend/turn/turnserver.conf. Beispiel (ohne Secret – Secret kommt aus Env):
listening-port=3478
tls-listening-port=5349
realm=wizper.cloud
server-name=turn.wizper.cloud
# Authentication
lt-cred-mech
user=username:password
# Relay IPs
relay-ip=YOUR_SERVER_IP
external-ip=YOUR_PUBLIC_IPCall klingelt nicht:
- ✅ Prüfe ob Signaling Server läuft
- ✅ Prüfe Socket.io Connection in Browser Console
- ✅ Prüfe ob beide User online sind (Presence)
Kein Audio:
- ✅ Prüfe Mikrofon-Berechtigungen (Browser/iOS/Android)
- ✅ Prüfe Signaling-WebSocket und TURN (Admin: TURN Test)
- ✅ Prüfe ob beide Participants im Room sind
- ✅ Browser Console:
room.localParticipant.isMicrophoneEnabled
Schlechte Audio-Qualität:
- ✅ Netzwerk-Qualität prüfen
- ✅ TURN Server verwendet? (Network Tab: STUN vs TURN)
- ✅ TURN-Server erreichbar (siehe backend/DEPLOY-TURN.md)
Das Location System ermöglicht es Usern, ihre Stadt anzuzeigen und Matches in der Nähe zu finden.
┌─────────────────────────────────────────────────────────────┐
│ Location Service Flow │
├─────────────────────────────────────────────────────────────┤
│ │
│ User klickt "Enable Location" │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ 1. High Accuracy │ │
│ │ (GPS + WiFi) │ │
│ └────────┬───────────┘ │
│ │ │
│ ├─── Success ────► Coordinates (lat, lon) │
│ │ │ │
│ │ ▼ │
│ │ ┌─────────────────┐ │
│ │ │ 2. Reverse │ │
│ │ │ Geocoding │ │
│ │ │ (Nominatim) │ │
│ │ └────────┬────────┘ │
│ │ │ │
│ │ ▼ │
│ │ City Name │
│ │ │
│ └─── Fail ────► ┌────────────────────┐ │
│ │ 3. Low Accuracy │ │
│ │ (WiFi/Cell) │ │
│ └─────────┬──────────┘ │
│ │ │
│ ├─── Success ────► │
│ │ Coordinates │
│ │ │ │
│ │ ▼ │
│ │ Reverse Geocoding │
│ │ │
│ └─── Fail ────► │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 4. IP Fallback │ │
│ │ (ipapi.co) │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ City + Coords │
│ │
└─────────────────────────────────────────────────────────────┘
LocationStep.tsx
const handleEnableLocation = async () => {
// 1. High Accuracy versuchen (GPS)
try {
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 5000,
maximumAge: 0
});
});
const { latitude, longitude } = position.coords;
const city = await reverseGeocode(latitude, longitude);
setDetectedCity(city);
setCoordinates({ lat: latitude, lon: longitude });
return;
} catch (highAccuracyError) {
console.warn('High accuracy failed, trying low accuracy...');
}
// 2. Low Accuracy versuchen (WiFi/Cell)
try {
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: false,
timeout: 15000,
maximumAge: Infinity // Cache erlauben
});
});
const { latitude, longitude } = position.coords;
const city = await reverseGeocode(latitude, longitude);
setDetectedCity(city);
setCoordinates({ lat: latitude, lon: longitude });
return;
} catch (lowAccuracyError) {
console.warn('Low accuracy failed, trying IP fallback...');
}
// 3. IP-based Fallback
try {
const response = await fetch('https://ipapi.co/json/');
const data = await response.json();
if (data.city && data.latitude && data.longitude) {
setCoordinates({ lat: data.latitude, lon: data.longitude });
setDetectedCity(data.city);
return;
}
} catch (ipError) {
throw new Error('All location methods failed');
}
};OpenStreetMap Nominatim API:
const reverseGeocode = async (
latitude: number,
longitude: number
): Promise<string> => {
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?` +
`lat=${latitude}&lon=${longitude}&format=json&addressdetails=1`,
{
headers: {
'User-Agent': 'Wizper Dating App'
}
}
);
const data = await response.json();
// Hierarchie für beste Stadt
const city = data.address?.city || // Stadt
data.address?.town || // Kleinstadt
data.address?.village || // Dorf
data.address?.municipality || // Gemeinde
data.address?.county || // Landkreis
'Unknown Location';
return city;
};Rate Limits:
- Nominatim: 1 Request / Sekunde
- User-Agent Header erforderlich
- Kostenlos, kein API Key
iOS (ios/App/App/Info.plist):
<key>NSLocationWhenInUseUsageDescription</key>
<string>Wizper uses your location to show your nearest city and help you find matches nearby.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Wizper uses your location to show your nearest city and help you find matches nearby.</string>Android (android/app/src/main/AndroidManifest.xml):
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />Browser zeigt Error Code 1 (Permission Denied):
- Chrome: Klick auf 🔒 → Site Settings → Location → Allow
- Safari: Safari → Settings → Websites → Location Services
- macOS: System Settings → Privacy & Security → Location Services
Browser zeigt Error Code 2 (Position Unavailable):
- System Location Services aus?
- VPN/Proxy blockiert?
- macOS: Chrome/Safari brauchen Location Permission
- iOS Simulator: Nutze "Custom Location" in Simulator
Browser zeigt Error Code 3 (Timeout):
- Langsame Internetverbindung
- GPS Signal schwach (Indoor)
- Timeout erhöhen:
timeout: 30000
IP Fallback liefert falsche Stadt:
- IP-based Location ist ungenau (kann 50km+ abweichen)
- User sollte manuell korrigieren können
- Alternative: Google Geocoding API (kostenpflichtig, genauer)
User können 10-30 Sekunden Voice-Samples aufnehmen für ihr Profil.
VoiceStep.tsx
const startRecording = async () => {
// 1. Request Microphone Permission
const stream = await navigator.mediaDevices.getUserMedia({
audio: true
});
// 2. Create MediaRecorder
const mediaRecorder = new MediaRecorder(stream);
const chunks: Blob[] = [];
// 3. Collect audio data
mediaRecorder.ondataavailable = (event) => {
chunks.push(event.data);
};
// 4. On stop, create Blob
mediaRecorder.onstop = () => {
const blob = new Blob(chunks, { type: 'audio/webm' });
const duration = Math.round((Date.now() - startTime) / 1000);
setVoiceRecording({ blob, duration });
stream.getTracks().forEach(track => track.stop());
};
// 5. Start recording
mediaRecorder.start();
startTime = Date.now();
};1. Upload zu Supabase Storage
const filename = `${userId}/${Date.now()}.webm`;
const { data: uploadData, error: uploadError } = await supabase.storage
.from('voice-samples')
.upload(filename, voiceBlob);2. Metadata in Database
await supabase.from('voice_samples').insert({
user_id: userId,
storage_path: uploadData.path,
duration_seconds: duration,
is_primary: true
});3. Abspielen
// Public URL holen
const { data } = supabase.storage
.from('voice-samples')
.getPublicUrl(storagePath);
// Audio Element erstellen
const audio = new Audio(data.publicUrl);
audio.play();-- Bucket erstellen (via Supabase Dashboard)
INSERT INTO storage.buckets (id, name, public)
VALUES ('voice-samples', 'voice-samples', true);
-- RLS Policy für Upload
CREATE POLICY "Users can upload their own voice samples"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'voice-samples' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- RLS Policy für Read
CREATE POLICY "Voice samples are publicly readable"
ON storage.objects FOR SELECT
TO public
USING (bucket_id = 'voice-samples');iOS (Info.plist):
<key>NSMicrophoneUsageDescription</key>
<string>Wizper needs access to your microphone for voice calls and recording your profile intro.</string>Android (AndroidManifest.xml):
<uses-permission android:name="android.permission.RECORD_AUDIO" />Ein mehrstufiges Sicherheitssystem, um Frauen volle Kontrolle über Interaktionen zu geben.
┌─────────────────────────────────────────────────────────────┐
│ Anruf-Berechtigungs-System │
├─────────────────────────────────────────────────────────────┤
│ │
│ Frauen → Männer: ✅ Immer erlaubt │
│ Männer → Frauen: 🔒 Nur nach Like │
│ Gleiche Gender: ✅ Immer erlaubt │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Mann möchte Frau anrufen │ │
│ ├────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ 1. Mann sendet Sprachnachricht an Frau │ │
│ │ ↓ │ │
│ │ 2. Frau hört Nachricht in ihrem Postfach │ │
│ │ ↓ │ │
│ │ 3. Frau liked die Nachricht (❤️) │ │
│ │ ↓ │ │
│ │ 4. Anruf-Button wird für Mann aktiviert │ │
│ │ │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Jede empfangene Sprachnachricht zeigt:
| Information | Beschreibung |
|---|---|
| Name | Username des Absenders |
| Geschlecht | Badge mit Farbe (♀ pink, ♂ blau) |
| Alter | Berechnet aus Geburtstag |
| Stadt | Wohnort des Absenders |
| Entfernung | ~X km (wenn Location sichtbar) |
| Datum/Zeit | Wann die Nachricht gesendet wurde |
| Aktion | Beschreibung |
|---|---|
| 📞 Anrufen | Startet Telefonat (bei Berechtigung) |
| ❤️ Like | Positives Feedback, aktiviert Anruf-Berechtigung für Absender |
| 🚫 Blockieren | User blockieren, keine weiteren Nachrichten |
| 🚩 Melden | Unangemessenes Verhalten melden |
-- Tracking von Likes auf Sprachnachrichten
voice_messages.is_liked -- Boolean: Wurde Nachricht geliked?
voice_messages.liked_at -- Timestamp: Wann wurde geliked?
-- User-Blockierungen
user_blocks (blocker_id, blocked_id, reason)
-- User-Meldungen
user_reports (reporter_id, reported_id, voice_message_id, reason, status)
-- Serverseitige Prüfung
can_initiate_call(caller_id, receiver_id) -- PostgreSQL Function- Server-seitige Validierung: RLS Policies + PostgreSQL Function
- Rate Limiting: Datenbank-Trigger verhindern Spam
- Keine irreführenden CTAs: Button deaktiviert mit Erklärung
- Moderation: Report-System für unangemessenes Verhalten
Wizper ist als PWA implementiert und kann auf Android und iOS direkt installiert werden.
- Installierbar: App kann auf dem Homescreen hinzugefügt werden
- Offline-fähig: Grundlegende Funktionen auch ohne Internet
- Push-Benachrichtigungen: Eingehende Anrufe werden als Notifications angezeigt
- Native App-Feeling: Vollbild-Modus ohne Browser-UI
┌─────────────────────────────────────────────────────────────┐
│ PWA Architecture │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────┐ │
│ │ manifest.json │ ← App-Metadaten, Icons, Theme │
│ └────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────┐ ┌──────────────────┐ │
│ │ Service Worker │◄──►│ Cache Storage │ │
│ │ (sw.js) │ │ (Offline Assets) │ │
│ └────────────────┘ └──────────────────┘ │
│ │ │
│ │ Push Events │
│ ▼ │
│ ┌────────────────┐ │
│ │ Notifications │ │
│ │ (Incoming Calls)│ │
│ └────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ PWAInstallPrompt Component │ │
│ ├────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ Android/Chrome: iOS Safari: │ │
│ │ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │beforeinstall │ │ Manual Instructions │ │ │
│ │ │prompt Event │ │ (Share → Add to Home)│ │ │
│ │ └──────────────┘ └──────────────────────┘ │ │
│ │ │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Android (Chrome):
- Login-Seite besuchen
- "App installieren" Button klicken
- Chrome zeigt nativen Install-Dialog
- "Installieren" bestätigen
- App erscheint auf dem Homescreen
iOS (Safari):
- Login-Seite besuchen
- "So geht's" Button klicken
- Anleitung befolgen:
- Share-Button (⬆) tippen
- "Zum Home-Bildschirm" wählen
- "Hinzufügen" bestätigen
Desktop (Chrome/Edge):
- Login-Seite besuchen
- "App installieren" Button klicken
- Im Dialog "Installieren" bestätigen
| Datei | Zweck |
|---|---|
public/manifest.webmanifest |
PWA-Konfiguration (Name, Icons, Theme) |
public/sw.js |
Service Worker (Caching, Push Notifications) |
public/icons/ |
App-Icons in verschiedenen Größen |
src/shared/components/PWAInstallPrompt.tsx |
Install-Banner Komponente |
// Caching-Strategie: Network First, Cache Fallback
// - Versucht zuerst vom Netzwerk zu laden
// - Bei Offline: Cached Version verwenden
// - API-Requests werden nicht gecached
// Push Notifications für:
// - Eingehende Anrufe
// - Mit Accept/Decline ActionsFür die Produktion sollten PNG-Icons generiert werden:
# Mit einem Tool wie sharp oder ImageMagick:
# SVG zu PNG konvertieren in verschiedenen Größen
convert public/icons/icon.svg -resize 192x192 public/icons/icon-192x192.png
convert public/icons/icon.svg -resize 512x512 public/icons/icon-512x512.pngOder Online-Tools verwenden:
Install-Button erscheint nicht (Android):
- ✅ HTTPS erforderlich (localhost ist Ausnahme)
- ✅ Manifest korrekt verlinkt?
- ✅ Service Worker registriert?
- ✅ Chrome DevTools → Application → Manifest prüfen
iOS zeigt keine Install-Option:
- iOS unterstützt keine automatische Installation
- User muss manuell über Share → Add to Home Screen gehen
- Die App zeigt entsprechende Anleitung
Offline funktioniert nicht:
- ✅ Service Worker aktiv? (DevTools → Application → Service Workers)
- ✅ Cache gefüllt? (DevTools → Application → Cache Storage)
- ✅ Network-Tab: Requests von Service Worker?
Push Notifications kommen nicht an:
- ✅ Notification Permission erteilt?
- ✅ Service Worker aktiv?
- ✅ Push-Subscription erfolgreich?
- Node.js 18+ (Download)
- npm 9+
- Git
- Supabase Account (supabase.com)
- Stripe Account (stripe.com)
git clone https://github.com/yourusername/wizper.git
cd wizper# Frontend
npm install
# Backend API
cd api
npm install
cd ..
# Signaling Server
cd backend/signaling
npm install
cd ../..Frontend (.env):
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_xxx
VITE_STRIPE_WEEKLY_PRICE_ID=price_xxx
VITE_STRIPE_MONTHLY_PRICE_ID=price_xxxBackend API (api/.env):
PORT=3001
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
VITE_APP_URL=http://localhost:5173Signaling Server (backend/signaling/.env):
PORT=3002- Erstelle neues Supabase Projekt
- Führe Migrations aus:
cd supabase # Kopiere SQL aus migrations/ ins Supabase SQL Editor
- Erstelle Storage Buckets:
voice-samples(public)profile-pictures(public)
- Erstelle Produkte & Preise:
- Premium Weekly (0,99€ / Woche)
- Premium Monthly (2,99€ / Monat)
- Kopiere Price IDs in
.env - Setup Webhook:
- URL:
https://your-api.com/api/webhook - Events:
customer.subscription.created,customer.subscription.updated,customer.subscription.deleted
- URL:
# Terminal 1: Frontend
npm run dev
# → http://localhost:5173
# Terminal 2: Backend API
cd api
npm start
# → http://localhost:3001
# Terminal 3: Signaling Server
cd backend/signaling
npm start
# → http://localhost:3002iOS:
npm run build:ios
npm run cap:ios
# Xcode öffnet sichAndroid:
npm run build:android
npm run cap:android
# Android Studio öffnet sich| Variable | Beschreibung | Beispiel |
|---|---|---|
VITE_SUPABASE_URL |
Supabase Project URL | https://xxx.supabase.co |
VITE_SUPABASE_ANON_KEY |
Supabase Anon/Public Key | eyJh... |
VITE_STRIPE_PUBLISHABLE_KEY |
Stripe Public Key | pk_test_... oder pk_live_... |
VITE_STRIPE_WEEKLY_PRICE_ID |
Stripe Price ID (Weekly) | price_1Qk4K4... |
VITE_STRIPE_MONTHLY_PRICE_ID |
Stripe Price ID (Monthly) | price_1Qk4K4... |
VITE_API_URL |
Backend API URL (Prod only) | https://api.wizper.cloud |
VITE_SIGNALING_URL |
WebSocket Signaling URL | wss://signaling.wizper.cloud |
VITE_TURN_URL |
TURN Server URL | turn:turn.wizper.cloud:3478 |
VITE_TURN_SECRET |
TURN Secret (zeitbasierte Auth) | siehe backend/DEPLOY-TURN.md |
| Variable | Beschreibung | Beispiel |
|---|---|---|
PORT |
API Server Port | 3001 |
SUPABASE_URL |
Supabase Project URL | https://xxx.supabase.co |
SUPABASE_SERVICE_ROLE_KEY |
Supabase Service Role Key (Admin) | eyJh... |
STRIPE_SECRET_KEY |
Stripe Secret Key | sk_test_... oder sk_live_... |
STRIPE_WEBHOOK_SECRET |
Stripe Webhook Secret | whsec_... |
VITE_APP_URL |
Frontend URL (for redirects) | https://wizper.cloud |
| Variable | Beschreibung | Beispiel |
|---|---|---|
PORT |
Signaling Server Port | 3002 |
CORS_ORIGIN |
Allowed CORS Origins | https://wizper.cloud,http://localhost:5173 |
Empfohlene Plattformen:
- Vercel
- Netlify
- Cloudflare Pages
Build:
npm run build
# Output: dist/Wichtig:
- Setze
VITE_API_URLauf Production API URL - Configure SPA Fallback (alle Routes →
index.html)
Empfohlene Plattformen:
- Railway
- Fly.io
- Render
- DigitalOcean App Platform
Dockerfile:
FROM node:22-alpine AS builder
WORKDIR /app
COPY api/package*.json ./
RUN npm ci
COPY api/ .
CMD ["npm", "start"]Wichtig:
- Stripe Webhook URL konfigurieren
- Environment Variables setzen
- Port aus
process.env.PORTlesen
Gleiche Plattform wie API Server
Dockerfile:
FROM node:22-alpine
WORKDIR /app
COPY backend/signaling/package*.json ./
RUN npm ci
COPY backend/signaling/ .
CMD ["npm", "start"]iOS:
npm run build:ios
# In Xcode:
# 1. Archive erstellen
# 2. Distribute → App Store Connect
# 3. Submit for ReviewAndroid:
npm run build:android
# In Android Studio:
# 1. Build → Generate Signed Bundle
# 2. Upload to Google Play Console
# 3. Submit for Review"Customer ID and Payment Method ID are required"
- ✅ Check: Age Verification completed?
- ✅ Check:
stripe_customer_idin Supabase users table? - ✅ Check:
initialCustomerIdpassed to SubscriptionStep? - ✅ Check: Backend logs for Stripe API errors
Age Verification fails
- ✅ Check: Stripe keys correct? (Test mode vs Live mode)
- ✅ Check: Card valid? (Use Stripe test cards in dev)
- ✅ Check: Browser console for Stripe.js errors
Call doesn't ring
- ✅ Check: Signaling Server running?
- ✅ Check: Socket.io connected? (Browser console)
- ✅ Check: Both users online? (Presence)
- ✅ Check: Firewall blocking WebSocket?
No audio
- ✅ Check: Microphone permissions granted?
- ✅ Check: Signaling WebSocket connected? TURN erreichbar? (Admin: TURN Test)
- ✅ Check: PeerConnection state in console
Poor audio quality
- ✅ Check: Network quality (3G vs WiFi)
- ✅ Check: TURN server used? (Chrome DevTools → Network)
- ✅ Check: TURN-Server erreichbar (backend/DEPLOY-TURN.md)
"Position unavailable"
- ✅ Check: System Location Services enabled?
- ✅ Check: Browser has location permission?
- ✅ macOS: System Settings → Privacy → Location Services
- ✅ Browser: Site Settings → Location → Allow
Wrong city detected
- ✅ IP-based location can be 50km+ off
- ✅ GPS/WiFi location more accurate
- ✅ User can manually correct in profile
"Invalid input value for enum"
- ✅ Check: Run latest migrations?
- ✅ Check: ENUM updated in Supabase?
- ✅ Check: TypeScript types match DB schema?
RLS Policy errors
- ✅ Check: User authenticated?
- ✅ Check: Policy allows operation?
- ✅ Supabase Dashboard → Database → Policies
TypeScript errors
- ✅ Run:
npm run buildlocally first - ✅ Check: All dependencies installed?
- ✅ Check: TypeScript version matches?
Environment variables missing
- ✅ Check: All required vars set?
- ✅ Check:
VITE_prefix for frontend vars? - ✅ Check: Vercel/Railway dashboard for correct values
- Supabase Docs: https://supabase.com/docs
- Stripe Docs: https://stripe.com/docs
- Coturn (TURN): https://github.com/coturn/coturn
- React Router: https://reactrouter.com
- Capacitor: https://capacitorjs.com/docs
- ✅ Multi-Step Onboarding implementiert
- ✅ Age Verification mit Stripe Setup Intent
- ✅ Premium Subscriptions (Weekly/Monthly)
- ✅ Native WebRTC Voice Calling (Signaling + TURN/Coturn)
- ✅ Location Services mit 3-Tier Fallback
- ✅ Voice Recording & Playback
- ✅ Customer ID Management Fix
- ✅ Discovery Page Reload Bug Fix
- ✅ Voice Message Inbox (Postfach für Sprachnachrichten)
- ✅ Comprehensive README Documentation
- ✅ Fix: voice_messages query 400 Bad Request - Spaltenname
is_readzuis_listenedkorrigiert (Datenbank verwendetis_listened) - ✅ PWA Implementation - Progressive Web App mit Install-Prompts für Android und iOS
- ✅ Erweitertes Sprach-Postfach mit Absender-Kontext (Alter, Stadt, Distanz, Geschlecht)
- ✅ Geschütztes Telefonie-System: Frauen kontrollieren, wer sie anrufen darf
- ✅ Like/Block/Report Aktionen für jede Sprachnachricht
- ✅ Fix: Logout hängt bei "Loading..." - Timeout für
supabase.auth.signOut()hinzugefügt, localStorage-Tokens werden sofort gelöscht
Erstellt mit ❤️ für authentische Verbindungen
Bei Fragen oder Issues: GitHub Issues