Production deployment setup + feature complete

- Dockerfile + deploy.sh for Hetzner server
- Email verification via Better Auth + Resend
- Invite code flow (6-digit OTP, generate/join)
- Settlement share percent fix (payer vs debtor)
- OCR scanner fixes (date display, retry, viewfinder)
- app.json icon/splash/adaptive-icon configured
- iOS deployment target 15.5 (ML Kit requirement)
- DB migration 0014: household_invitations table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
René Schober
2026-03-20 11:54:22 +01:00
parent 4e34270786
commit 9ddc7c6d7a
194 changed files with 55961 additions and 305 deletions

View File

@@ -0,0 +1,14 @@
import { Stack } from "expo-router";
export default function AuthLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="login" />
<Stack.Screen name="register" />
<Stack.Screen name="onboarding" />
<Stack.Screen name="setup" />
<Stack.Screen name="forgot-password" />
<Stack.Screen name="reset-password" />
</Stack>
);
}

View File

@@ -0,0 +1,100 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Pressable,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { authClient } from "@/src/lib/auth-client";
export default function ForgotPasswordScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [email, setEmail] = useState("");
const [isPending, setIsPending] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSend() {
if (!email.trim()) return;
setIsPending(true);
setError(null);
const result = await authClient.requestPasswordReset({
email: email.trim(),
redirectTo: "haushaltsApp://reset-password",
});
setIsPending(false);
if (result.error) {
setError(result.error.message ?? t('common.error'));
} else {
setSent(true);
}
}
return (
<KeyboardAvoidingView
className="flex-1 bg-white"
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<View style={{ paddingTop: insets.top }} className="px-6 pt-4 pb-2 flex-row items-center">
<Pressable onPress={() => router.back()} className="p-1 mr-2 active:opacity-50">
<Ionicons name="chevron-back" size={22} color="#374151" />
</Pressable>
</View>
<View className="flex-1 px-6 pt-8">
<Text className="text-2xl font-bold text-gray-900 mb-2">{t('forgotPassword.title')}</Text>
<Text className="text-sm text-gray-500 mb-8">{t('forgotPassword.subtitle')}</Text>
{sent ? (
<View className="bg-green-50 rounded-2xl p-5 items-center" style={{ borderWidth: 1, borderColor: "#bbf7d0" }}>
<Ionicons name="checkmark-circle" size={48} color="#16a34a" style={{ marginBottom: 12 }} />
<Text className="text-base font-semibold text-green-800 text-center mb-1">{t('forgotPassword.sentTitle')}</Text>
<Text className="text-sm text-green-600 text-center">{t('forgotPassword.sentHint')}</Text>
</View>
) : (
<>
<Text className="text-sm font-medium text-gray-700 mb-2">{t('login.emailLabel')}</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
placeholder={t('login.emailPlaceholder')}
placeholderTextColor="#9ca3af"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>
{error && (
<Text className="text-sm text-red-500 mb-4">{error}</Text>
)}
<Pressable
onPress={handleSend}
disabled={isPending || !email.trim()}
className="rounded-2xl py-4 items-center active:opacity-80"
style={{ backgroundColor: isPending || !email.trim() ? "#e5e7eb" : "#2563EB" }}
>
{isPending ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="text-base font-semibold" style={{ color: !email.trim() ? "#9ca3af" : "#fff" }}>
{t('forgotPassword.sendButton')}
</Text>
)}
</Pressable>
</>
)}
</View>
</KeyboardAvoidingView>
);
}

View File

@@ -0,0 +1,182 @@
import { signIn, authClient } from "@/src/lib/auth-client";
import { apiRequest } from "@/src/lib/api-client";
import { useAuthStore, type Household } from "@/src/stores/auth.store";
import { useRouter } from "expo-router";
import { useState } from "react";
import * as AppleAuthentication from "expo-apple-authentication";
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Pressable,
Text,
TextInput,
View,
} from "react-native";
import { useTranslation } from "react-i18next";
export default function LoginScreen() {
const router = useRouter();
const { t } = useTranslation();
const { setUser, setHouseholds, setActiveHousehold } = useAuthStore();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleEmailSignIn() {
if (!email || !password) {
setError(t('login.fillAllFields'));
return;
}
setIsLoading(true);
setError(null);
try {
const result = await signIn.email({ email, password });
if (result.error) {
const msg = result.error.message ?? "";
if (msg.toLowerCase().includes("email") && msg.toLowerCase().includes("verif")) {
router.push({ pathname: "/(auth)/verify-email", params: { email } });
return;
}
setError(msg || t('login.signInError'));
return;
}
if (result.data?.user) setUser(result.data.user);
try {
const { households } = await apiRequest<{ households: Household[] }>("/api/households");
setHouseholds(households);
if (households.length > 0) setActiveHousehold(households[0].id);
} catch {
// households will be loaded on next app start
}
router.replace("/(app)/haushalt");
} catch {
setError(t('login.signInError'));
} finally {
setIsLoading(false);
}
}
async function handleAppleSignIn() {
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
if (!credential.identityToken) return;
const result = await authClient.signIn.social({
provider: "apple",
idToken: { token: credential.identityToken },
});
if (result.error) {
setError(result.error.message ?? t('common.error'));
return;
}
// session is handled by authClient interceptor
router.replace("/(app)/haushalt");
} catch (err: unknown) {
if ((err as { code?: string }).code !== "ERR_CANCELED") {
setError(t('login.appleSignInError'));
}
}
}
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className="flex-1 bg-white"
>
<View className="flex-1 justify-center px-6">
<Text className="mb-2 text-3xl font-bold text-gray-900">
{t('login.welcome')}
</Text>
<Text className="mb-8 text-base text-gray-500">
{t('login.subtitle')}
</Text>
{error && (
<View className="mb-4 rounded-lg bg-red-50 p-3">
<Text className="text-sm text-red-600">{error}</Text>
</View>
)}
{Platform.OS === "ios" && (
<>
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN}
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={12}
style={{ width: "100%", height: 50 }}
onPress={handleAppleSignIn}
/>
<View className="flex-row items-center gap-3 my-4">
<View className="flex-1 h-px bg-gray-200" />
<Text className="text-xs text-gray-400">{t('common.or')}</Text>
<View className="flex-1 h-px bg-gray-200" />
</View>
</>
)}
<View className="mb-4">
<Text className="mb-1.5 text-sm font-medium text-gray-700">
{t('login.emailLabel')}
</Text>
<TextInput
className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-base text-gray-900"
placeholder={t('login.emailPlaceholder')}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
/>
</View>
<View className="mb-6">
<Text className="mb-1.5 text-sm font-medium text-gray-700">
{t('login.passwordLabel')}
</Text>
<TextInput
className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-base text-gray-900"
placeholder={t('login.passwordPlaceholder')}
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="password"
/>
</View>
<Pressable
onPress={handleEmailSignIn}
disabled={isLoading}
className="mb-3 items-center rounded-xl bg-blue-600 py-4 active:opacity-80"
>
{isLoading ? (
<ActivityIndicator color="white" />
) : (
<Text className="text-base font-semibold text-white">{t('login.signIn')}</Text>
)}
</Pressable>
<Pressable
onPress={() => router.push("/(auth)/forgot-password")}
className="mb-6 items-center py-2 active:opacity-60"
>
<Text className="text-sm text-blue-600">{t('login.forgotPassword')}</Text>
</Pressable>
<View className="flex-row justify-center">
<Text className="text-sm text-gray-500">{t('login.noAccount')} </Text>
<Pressable onPress={() => router.push("/(auth)/register")}>
<Text className="text-sm font-semibold text-blue-600">
{t('login.register')}
</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
);
}

View File

@@ -0,0 +1,363 @@
import { authClient } from "@/src/lib/auth-client";
import { apiRequest } from "@/src/lib/api-client";
import { useAuthStore } from "@/src/stores/auth.store";
import { useJoinWithCode } from "@/src/hooks/useInvite";
import { useRouter } from "expo-router";
import { useRef, useState } from "react";
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Pressable,
Text,
TextInput,
View,
} from "react-native";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
// Which top-level path the user is on
type Mode = "choose" | "create" | "join";
// ── OTP Box Input ─────────────────────────────────────────────────────────────
function OtpInput({
onComplete,
hasError,
}: {
onComplete: (code: string) => void;
hasError: boolean;
}) {
const [digits, setDigits] = useState<string[]>(["", "", "", "", "", ""]);
const refs = useRef<Array<TextInput | null>>([]);
function handleChange(text: string, index: number) {
// Handle paste: if pasted text fills all 6 slots
if (text.length === 6) {
const upper = text.toUpperCase();
const filled = upper.split("").slice(0, 6);
setDigits(filled);
refs.current[5]?.focus();
onComplete(upper);
return;
}
// Handle paste into a single box that is actually 2 chars (current + new char)
const char = text.slice(-1).toUpperCase();
const newDigits = [...digits];
newDigits[index] = char;
setDigits(newDigits);
if (char && index < 5) {
refs.current[index + 1]?.focus();
}
if (newDigits.every((d) => d !== "")) {
onComplete(newDigits.join(""));
}
}
function handleKeyPress(key: string, index: number) {
if (key === "Backspace") {
const newDigits = [...digits];
if (digits[index] === "" && index > 0) {
newDigits[index - 1] = "";
setDigits(newDigits);
refs.current[index - 1]?.focus();
} else {
newDigits[index] = "";
setDigits(newDigits);
}
}
}
return (
<View style={{ flexDirection: "row", gap: 8, justifyContent: "center" }}>
{digits.map((digit, i) => (
<TextInput
key={i}
ref={(el) => {
refs.current[i] = el;
}}
value={digit}
onChangeText={(text) => handleChange(text, i)}
onKeyPress={({ nativeEvent }) => handleKeyPress(nativeEvent.key, i)}
autoCapitalize="characters"
autoCorrect={false}
maxLength={2}
selectTextOnFocus
style={{
width: 48,
height: 56,
borderRadius: 12,
borderWidth: 1.5,
borderColor: hasError ? "#dc2626" : digit ? "#2563EB" : "#e5e7eb",
backgroundColor: "#f9fafb",
textAlign: "center",
fontSize: 24,
fontWeight: "700",
color: "#111827",
}}
/>
))}
</View>
);
}
// ── Main Screen ───────────────────────────────────────────────────────────────
export default function OnboardingScreen() {
const router = useRouter();
const { t } = useTranslation();
const { setActiveHousehold, setHouseholds, households } = useAuthStore();
const [mode, setMode] = useState<Mode>("choose");
const [householdName, setHouseholdName] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingCode, setPendingCode] = useState("");
const { mutate: joinWithCode, isPending: isJoining } = useJoinWithCode();
async function handleCreateHousehold() {
if (!householdName.trim()) {
setError(t('onboarding.enterHouseholdName'));
return;
}
setIsCreating(true);
setError(null);
try {
const result = await authClient.organization.create({
name: householdName.trim(),
slug: householdName.trim().toLowerCase().replace(/\s+/g, "-"),
});
if (result.error) {
setError(result.error.message ?? t('onboarding.createError'));
return;
}
if (result.data?.id) {
const organizationId = result.data.id;
const newHousehold = { id: organizationId, name: householdName.trim(), role: "owner" };
// Bridge: create households row + seed default categories
await apiRequest("/api/households/setup", {
method: "POST",
headers: { "x-household-id": organizationId },
});
// Append to existing list, keep current active household
setHouseholds([...households, newHousehold]);
// Only switch if this is the first household (initial onboarding)
if (households.length === 0) {
setActiveHousehold(organizationId);
router.replace("/(auth)/setup");
} else {
router.back();
}
}
} catch {
setError(t('onboarding.createError'));
} finally {
setIsCreating(false);
}
}
function handleCodeComplete(code: string) {
setPendingCode(code);
}
function handleJoinSubmit() {
const code = pendingCode.trim().toUpperCase();
if (code.length !== 6) {
setError(t('onboarding.enterInviteCode'));
return;
}
setError(null);
joinWithCode(code, {
onSuccess: async (data) => {
const newHousehold = {
id: data.householdId,
name: data.householdName,
role: "member",
};
setHouseholds([...households, newHousehold]);
setActiveHousehold(data.householdId);
router.replace("/(app)/haushalt");
},
onError: (err) => {
const msg = err.message ?? t('invite.invalidCode');
if (msg.toLowerCase().includes("already")) {
setError(t('invite.alreadyMember'));
} else {
setError(t('invite.invalidCode'));
}
},
});
}
// ── Choose screen ────────────────────────────────────────────────────────────
if (mode === "choose") {
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className="flex-1 bg-white"
>
<View className="flex-1 justify-center px-6">
<Text className="mb-2 text-3xl font-bold text-gray-900">
{t('invite.setupTitle')}
</Text>
<Text className="mb-10 text-base text-gray-500">
{t('onboarding.setupSubtitle')}
</Text>
{/* Create new */}
<Pressable
onPress={() => { setError(null); setMode("create"); }}
className="mb-4 rounded-xl border border-gray-200 bg-white p-5 active:opacity-70"
>
<View className="flex-row items-center gap-4">
<View className="w-11 h-11 rounded-full bg-blue-50 items-center justify-center">
<Ionicons name="add-circle-outline" size={24} color="#2563EB" />
</View>
<View className="flex-1">
<Text className="text-base font-semibold text-gray-900">
{t('invite.createNew')}
</Text>
<Text className="text-sm text-gray-400">{t('invite.createNewSub')}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color="#9ca3af" />
</View>
</Pressable>
{/* Join with code */}
<Pressable
onPress={() => { setError(null); setMode("join"); }}
className="rounded-xl border border-gray-200 bg-white p-5 active:opacity-70"
>
<View className="flex-row items-center gap-4">
<View className="w-11 h-11 rounded-full bg-blue-50 items-center justify-center">
<Ionicons name="key-outline" size={24} color="#2563EB" />
</View>
<View className="flex-1">
<Text className="text-base font-semibold text-gray-900">
{t('invite.enterCode')}
</Text>
<Text className="text-sm text-gray-400">{t('invite.enterCodeSub')}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color="#9ca3af" />
</View>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
// ── Create screen ────────────────────────────────────────────────────────────
if (mode === "create") {
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className="flex-1 bg-white"
>
<View className="flex-1 justify-center px-6">
{/* Back */}
<Pressable
onPress={() => { setError(null); setMode("choose"); }}
className="mb-6 flex-row items-center gap-1 self-start active:opacity-60"
>
<Ionicons name="chevron-back" size={18} color="#6b7280" />
<Text className="text-sm text-gray-500">{t('common.back')}</Text>
</Pressable>
<Text className="mb-2 text-3xl font-bold text-gray-900">
{t('onboarding.setupTitle')}
</Text>
<Text className="mb-8 text-base text-gray-500">
{t('onboarding.setupSubtitle')}
</Text>
{error && (
<View className="mb-4 rounded-lg bg-red-50 p-3">
<Text className="text-sm text-red-600">{error}</Text>
</View>
)}
<View className="mb-4">
<Text className="mb-1.5 text-sm font-medium text-gray-700">
{t('onboarding.householdNameLabel')}
</Text>
<TextInput
className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-base text-gray-900"
placeholder={t('onboarding.householdNamePlaceholder')}
value={householdName}
onChangeText={setHouseholdName}
autoComplete="off"
/>
</View>
<Pressable
onPress={handleCreateHousehold}
disabled={isCreating}
className="items-center rounded-xl bg-blue-600 py-4 active:opacity-80"
>
{isCreating ? (
<ActivityIndicator color="white" />
) : (
<Text className="text-base font-semibold text-white">
{t('onboarding.createHousehold')}
</Text>
)}
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
// ── Join screen ──────────────────────────────────────────────────────────────
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className="flex-1 bg-white"
>
<View className="flex-1 justify-center px-6">
{/* Back */}
<Pressable
onPress={() => { setError(null); setPendingCode(""); setMode("choose"); }}
className="mb-6 flex-row items-center gap-1 self-start active:opacity-60"
>
<Ionicons name="chevron-back" size={18} color="#6b7280" />
<Text className="text-sm text-gray-500">{t('common.back')}</Text>
</Pressable>
<Text className="mb-2 text-3xl font-bold text-gray-900">
{t('invite.joinTitle')}
</Text>
<Text className="mb-8 text-base text-gray-500">
{t('invite.joinHint')}
</Text>
<OtpInput onComplete={handleCodeComplete} hasError={!!error} />
{error && (
<View className="mt-4 rounded-lg bg-red-50 p-3">
<Text className="text-sm text-red-600 text-center">{error}</Text>
</View>
)}
<Pressable
onPress={handleJoinSubmit}
disabled={isJoining || pendingCode.length !== 6}
className="mt-8 items-center rounded-xl bg-blue-600 py-4 active:opacity-80 disabled:opacity-40"
>
{isJoining ? (
<ActivityIndicator color="white" />
) : (
<Text className="text-base font-semibold text-white">
{t('invite.joinButton')}
</Text>
)}
</Pressable>
</View>
</KeyboardAvoidingView>
);
}

View File

@@ -0,0 +1,181 @@
import { signUp, authClient } from "@/src/lib/auth-client";
import { useRouter } from "expo-router";
import { useState } from "react";
import * as AppleAuthentication from "expo-apple-authentication";
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Pressable,
Text,
TextInput,
View,
} from "react-native";
import { useTranslation } from "react-i18next";
export default function RegisterScreen() {
const router = useRouter();
const { t } = useTranslation();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleEmailRegister() {
if (!name || !email || !password) {
setError("Bitte alle Felder ausfüllen");
return;
}
if (password.length < 8) {
setError("Passwort muss mindestens 8 Zeichen lang sein");
return;
}
setIsLoading(true);
setError(null);
try {
const result = await signUp.email({
name,
email,
password,
callbackURL: "haushaltsApp://onboarding",
});
if (result.error) {
setError(result.error.message ?? "Registrierung fehlgeschlagen");
return;
}
// Email verification required — don't set user/session yet
router.replace({ pathname: "/(auth)/verify-email", params: { email } });
} catch {
setError("Registrierung fehlgeschlagen");
} finally {
setIsLoading(false);
}
}
async function handleAppleRegister() {
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
if (!credential.identityToken) return;
const result = await authClient.signIn.social({
provider: "apple",
idToken: { token: credential.identityToken },
});
if (result.error) {
setError(result.error.message ?? t('common.error'));
return;
}
// session is handled by authClient interceptor
router.replace("/(app)/haushalt");
} catch (err: unknown) {
if ((err as { code?: string }).code !== "ERR_CANCELED") {
setError(t('login.appleSignInError'));
}
}
}
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className="flex-1 bg-white"
>
<View className="flex-1 justify-center px-6">
<Text className="mb-2 text-3xl font-bold text-gray-900">
Konto erstellen
</Text>
<Text className="mb-8 text-base text-gray-500">
Starte deinen Haushalts-Manager
</Text>
{error && (
<View className="mb-4 rounded-lg bg-red-50 p-3">
<Text className="text-sm text-red-600">{error}</Text>
</View>
)}
{Platform.OS === "ios" && (
<>
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_UP}
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={12}
style={{ width: "100%", height: 50 }}
onPress={handleAppleRegister}
/>
<View className="flex-row items-center gap-3 my-4">
<View className="flex-1 h-px bg-gray-200" />
<Text className="text-xs text-gray-400">{t('common.or')}</Text>
<View className="flex-1 h-px bg-gray-200" />
</View>
</>
)}
<View className="mb-4">
<Text className="mb-1.5 text-sm font-medium text-gray-700">Name</Text>
<TextInput
className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-base text-gray-900"
placeholder="Dein Name"
value={name}
onChangeText={setName}
autoComplete="name"
/>
</View>
<View className="mb-4">
<Text className="mb-1.5 text-sm font-medium text-gray-700">
E-Mail
</Text>
<TextInput
className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-base text-gray-900"
placeholder="deine@email.de"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
/>
</View>
<View className="mb-6">
<Text className="mb-1.5 text-sm font-medium text-gray-700">
Passwort
</Text>
<TextInput
className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-base text-gray-900"
placeholder="Mindestens 8 Zeichen"
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="new-password"
/>
</View>
<Pressable
onPress={handleEmailRegister}
disabled={isLoading}
className="mb-3 items-center rounded-xl bg-blue-600 py-4 active:opacity-80"
>
{isLoading ? (
<ActivityIndicator color="white" />
) : (
<Text className="text-base font-semibold text-white">
Konto erstellen
</Text>
)}
</Pressable>
<View className="flex-row justify-center">
<Text className="text-sm text-gray-500">Bereits ein Konto? </Text>
<Pressable onPress={() => router.push("/(auth)/login")}>
<Text className="text-sm font-semibold text-blue-600">Anmelden</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
);
}

View File

@@ -0,0 +1,104 @@
import { useLocalSearchParams, useRouter } from "expo-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Pressable,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { authClient } from "@/src/lib/auth-client";
export default function ResetPasswordScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const { token } = useLocalSearchParams<{ token: string }>();
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [isPending, setIsPending] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const mismatch = confirm.length > 0 && password !== confirm;
const canSave = password.length >= 8 && password === confirm;
async function handleSave() {
if (!canSave || !token) return;
setIsPending(true);
setError(null);
const result = await authClient.resetPassword({ newPassword: password, token });
setIsPending(false);
if (result.error) {
setError(result.error.message ?? t('common.error'));
} else {
setDone(true);
setTimeout(() => router.replace("/(auth)/login"), 2000);
}
}
return (
<KeyboardAvoidingView
className="flex-1 bg-white"
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<View className="flex-1 px-6" style={{ paddingTop: insets.top + 40 }}>
<Text className="text-2xl font-bold text-gray-900 mb-2">{t('resetPassword.title')}</Text>
<Text className="text-sm text-gray-500 mb-8">{t('resetPassword.subtitle')}</Text>
{done ? (
<View className="bg-green-50 rounded-2xl p-5 items-center" style={{ borderWidth: 1, borderColor: "#bbf7d0" }}>
<Text className="text-base font-semibold text-green-800 text-center">{t('resetPassword.successMessage')}</Text>
</View>
) : (
<>
<Text className="text-sm font-medium text-gray-700 mb-2">{t('resetPassword.newPassword')}</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
placeholder="••••••••"
placeholderTextColor="#9ca3af"
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="new-password"
/>
<Text className="text-sm font-medium text-gray-700 mb-2">{t('resetPassword.confirmPassword')}</Text>
<TextInput
className={`bg-gray-50 border rounded-xl px-4 py-3 text-base text-gray-900 mb-1 ${mismatch ? "border-red-300" : "border-gray-200"}`}
placeholder="••••••••"
placeholderTextColor="#9ca3af"
value={confirm}
onChangeText={setConfirm}
secureTextEntry
autoComplete="new-password"
/>
{mismatch && <Text className="text-xs text-red-500 mb-3">{t('resetPassword.mismatch')}</Text>}
{!mismatch && <View className="mb-4" />}
{error && <Text className="text-sm text-red-500 mb-4">{error}</Text>}
<Pressable
onPress={handleSave}
disabled={isPending || !canSave}
className="rounded-2xl py-4 items-center active:opacity-80"
style={{ backgroundColor: !canSave ? "#e5e7eb" : "#2563EB" }}
>
{isPending ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="text-base font-semibold" style={{ color: !canSave ? "#9ca3af" : "#fff" }}>
{t('resetPassword.saveButton')}
</Text>
)}
</Pressable>
</>
)}
</View>
</KeyboardAvoidingView>
);
}

View File

@@ -0,0 +1,265 @@
import { useUpdateHouseholdSettings } from "@/src/hooks/useHouseholdSettings";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const ACCENT = "#2563EB";
const SHARE_PRESETS = [50, 60, 75, 100];
export default function SetupScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
const { mutate: updateSettings, isPending } = useUpdateHouseholdSettings();
const [step, setStep] = useState<1 | 2 | 3 | 4>(1);
const [ownerName, setOwnerName] = useState("");
const [partnerName, setPartnerName] = useState("");
const [userSharePercent, setUserSharePercent] = useState(50);
const [monthlyBudget, setMonthlyBudget] = useState("400");
const [splitChildCosts, setSplitChildCosts] = useState(true);
function handleSkip() {
finalize();
}
function finalize() {
const input = {
ownerName: ownerName.trim() || "Ich",
partnerName: partnerName.trim() || "Partner",
userSharePercent,
monthlyBudget: parseFloat(monthlyBudget.replace(",", ".")) || 400,
splitChildCosts,
onboardingComplete: true,
};
updateSettings(input, {
onSuccess: () => router.replace("/(app)/haushalt"),
onError: () => router.replace("/(app)/haushalt"),
});
}
return (
<View className="flex-1 bg-white" style={{ paddingTop: insets.top }}>
{/* Progress bar */}
<View className="flex-row px-6 pt-4 pb-2 gap-2">
{([1, 2, 3, 4] as const).map((s) => (
<View
key={s}
className="flex-1 h-1 rounded-full"
style={{ backgroundColor: s <= step ? ACCENT : "#e5e7eb" }}
/>
))}
</View>
{/* Skip */}
<View className="flex-row justify-end px-6 py-2">
<Pressable onPress={handleSkip} className="py-1 px-2 active:opacity-50">
<Text className="text-sm text-gray-400">{t('onboarding.skip')}</Text>
</Pressable>
</View>
<ScrollView
className="flex-1 px-6"
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingBottom: 40 }}
>
{/* Step 1 — Willkommen */}
{step === 1 && (
<View className="flex-1 items-center justify-center pt-16">
<View
className="w-20 h-20 rounded-3xl items-center justify-center mb-6"
style={{ backgroundColor: "#dbeafe" }}
>
<Text style={{ fontSize: 40 }}>💰</Text>
</View>
<Text className="text-3xl font-bold text-gray-900 text-center mb-3">
{t('onboarding.welcome')}
</Text>
<Text className="text-base text-gray-500 text-center leading-6">
{t('onboarding.subtitle')}
</Text>
</View>
)}
{/* Step 2 — Namen */}
{step === 2 && (
<View className="pt-8">
<Text className="text-2xl font-bold text-gray-900 mb-2">{t('setup.namesTitle')}</Text>
<Text className="text-base text-gray-500 mb-8">
{t('setup.namesHint')}
</Text>
<Text className="text-sm font-medium text-gray-700 mb-1.5">{t('onboarding.yourName')}</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
placeholder={t('onboarding.yourNamePlaceholder')}
value={ownerName}
onChangeText={setOwnerName}
autoCapitalize="words"
autoFocus
/>
<Text className="text-sm font-medium text-gray-700 mb-1.5">
{t('settings.household.partnerName')}
</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
placeholder={t('onboarding.partnerNamePlaceholder')}
value={partnerName}
onChangeText={setPartnerName}
autoCapitalize="words"
returnKeyType="next"
/>
</View>
)}
{/* Step 3 — Kostenaufteilung */}
{step === 3 && (
<View className="pt-8">
<Text className="text-2xl font-bold text-gray-900 mb-2">{t('setup.costSplitTitle')}</Text>
<Text className="text-base text-gray-500 mb-6">
{t('setup.costSplitHint')}
</Text>
{/* Preset buttons */}
<View className="flex-row gap-2 mb-6">
{SHARE_PRESETS.map((p) => (
<Pressable
key={p}
onPress={() => setUserSharePercent(p)}
className="flex-1 py-3 rounded-xl items-center"
style={{
backgroundColor: userSharePercent === p ? ACCENT : "#f3f4f6",
}}
>
<Text
className="text-sm font-semibold"
style={{ color: userSharePercent === p ? "#fff" : "#374151" }}
>
{p}%
</Text>
</Pressable>
))}
</View>
{/* Preview */}
<View
className="rounded-xl p-4 mb-6"
style={{ backgroundColor: "#eff6ff", borderWidth: 1, borderColor: "#bfdbfe" }}
>
<Text className="text-sm text-blue-700">
{t('onboarding.preview', {
own: userSharePercent,
partner: partnerName.trim() || 'Partner',
rest: 100 - userSharePercent,
})}
</Text>
</View>
{/* Monthly budget */}
<Text className="text-sm font-medium text-gray-700 mb-1.5">
{t('setup.monthlyBudgetLabel')}
</Text>
<View className="flex-row items-center bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 mb-6">
<Text className="text-base text-gray-400 mr-2"></Text>
<TextInput
className="flex-1 text-base text-gray-900"
placeholder="400"
value={monthlyBudget}
onChangeText={setMonthlyBudget}
keyboardType="decimal-pad"
/>
</View>
{/* Split child costs */}
<Text className="text-sm font-medium text-gray-700 mb-3">
{t('setup.splitChildCostsLabel')}
</Text>
<View className="flex-row gap-3">
<Pressable
onPress={() => setSplitChildCosts(true)}
className="flex-1 py-3 rounded-xl items-center"
style={{ backgroundColor: splitChildCosts ? ACCENT : "#f3f4f6" }}
>
<Text
className="text-sm font-semibold"
style={{ color: splitChildCosts ? "#fff" : "#374151" }}
>
{t('common.yes')}
</Text>
</Pressable>
<Pressable
onPress={() => setSplitChildCosts(false)}
className="flex-1 py-3 rounded-xl items-center"
style={{ backgroundColor: !splitChildCosts ? ACCENT : "#f3f4f6" }}
>
<Text
className="text-sm font-semibold"
style={{ color: !splitChildCosts ? "#fff" : "#374151" }}
>
{t('common.no')}
</Text>
</Pressable>
</View>
</View>
)}
{/* Step 4 — Fertig */}
{step === 4 && (
<View className="flex-1 items-center justify-center pt-16">
<View
className="w-20 h-20 rounded-3xl items-center justify-center mb-6"
style={{ backgroundColor: "#dcfce7" }}
>
<Ionicons name="checkmark-circle" size={44} color="#16a34a" />
</View>
<Text className="text-3xl font-bold text-gray-900 text-center mb-3">
{t('onboarding.done')}
</Text>
<Text className="text-base text-gray-500 text-center leading-6">
{t('onboarding.doneHint')}
</Text>
</View>
)}
</ScrollView>
{/* Bottom CTA */}
<View className="px-6 pb-8" style={{ paddingBottom: insets.bottom + 24 }}>
{step < 4 ? (
<Pressable
onPress={() => setStep(((step + 1) as 1 | 2 | 3 | 4))}
className="rounded-2xl py-4 items-center active:opacity-80"
style={{ backgroundColor: ACCENT }}
>
<Text className="text-base font-semibold text-white">
{step === 1 ? t('onboarding.start') : t('common.next')}
</Text>
</Pressable>
) : (
<Pressable
onPress={finalize}
disabled={isPending}
className="rounded-2xl py-4 items-center active:opacity-80"
style={{ backgroundColor: "#16a34a" }}
>
{isPending ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="text-base font-semibold text-white">{t('onboarding.startApp')}</Text>
)}
</Pressable>
)}
</View>
</View>
);
}

View File

@@ -0,0 +1,83 @@
import { authClient } from "@/src/lib/auth-client";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useState } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
export default function VerifyEmailScreen() {
const router = useRouter();
const { t } = useTranslation();
const { email } = useLocalSearchParams<{ email: string }>();
const [isResending, setIsResending] = useState(false);
const [resent, setResent] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleResend() {
if (!email) return;
setIsResending(true);
setError(null);
try {
await authClient.sendVerificationEmail({ email, callbackURL: "/" });
setResent(true);
setTimeout(() => setResent(false), 4000);
} catch {
setError(t('verifyEmail.resendError'));
} finally {
setIsResending(false);
}
}
return (
<View className="flex-1 bg-white items-center justify-center px-6">
<View className="w-16 h-16 rounded-full bg-blue-50 items-center justify-center mb-6">
<Ionicons name="mail-outline" size={32} color="#2563EB" />
</View>
<Text className="text-2xl font-bold text-gray-900 mb-2 text-center">
{t('verifyEmail.title')}
</Text>
<Text className="text-base text-gray-500 mb-2 text-center">
{t('verifyEmail.hint')}
</Text>
{email && (
<Text className="text-base font-semibold text-gray-900 mb-8 text-center">
{email}
</Text>
)}
{error && (
<View className="mb-4 rounded-lg bg-red-50 p-3 w-full">
<Text className="text-sm text-red-600 text-center">{error}</Text>
</View>
)}
{resent && (
<View className="mb-4 rounded-lg bg-green-50 p-3 w-full">
<Text className="text-sm text-green-700 text-center">{t('verifyEmail.resentConfirm')}</Text>
</View>
)}
<Pressable
onPress={handleResend}
disabled={isResending}
className="w-full items-center rounded-xl bg-blue-600 py-4 mb-4 active:opacity-80"
>
{isResending ? (
<ActivityIndicator color="white" />
) : (
<Text className="text-base font-semibold text-white">
{t('verifyEmail.resend')}
</Text>
)}
</Pressable>
<Pressable
onPress={() => router.replace("/(auth)/login")}
className="py-2 active:opacity-60"
>
<Text className="text-sm text-gray-500">{t('verifyEmail.backToLogin')}</Text>
</Pressable>
</View>
);
}