- 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>
364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|