diff --git a/app/(app)/_layout.tsx b/app/(app)/_layout.tsx
index 2aed1e9..ea94674 100644
--- a/app/(app)/_layout.tsx
+++ b/app/(app)/_layout.tsx
@@ -3,6 +3,7 @@ import { NativeTabs } from "expo-router/unstable-native-tabs";
import { AppLockOverlay } from "@/components/AppLockOverlay";
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
+import { OnboardingGate } from "@/components/OnboardingGate";
import { ShortcutHandler } from "@/components/ShortcutHandler";
import { TimeClockLiveActivitySync } from "@/components/time-clock/TimeClockLiveActivitySync";
import { useAppTheme } from "@/contexts/ThemeContext";
@@ -73,7 +74,16 @@ export default function AppLayout() {
/>
Settings
+
+
+
+ More
+
+
diff --git a/app/(app)/onboarding.tsx b/app/(app)/onboarding.tsx
new file mode 100644
index 0000000..7659082
--- /dev/null
+++ b/app/(app)/onboarding.tsx
@@ -0,0 +1,139 @@
+import { router } from "expo-router";
+import { useState } from "react";
+import { ScrollView, StyleSheet, Text, View } from "react-native";
+
+import { AppBackground } from "@/components/AppBackground";
+import { Button } from "@/components/ui/Button";
+import { Input } from "@/components/ui/Input";
+import { fonts, spacing } from "@/constants/theme";
+import { useAppTheme } from "@/contexts/ThemeContext";
+import { api } from "@/lib/trpc";
+
+export default function OnboardingScreen() {
+ const { colors } = useAppTheme();
+ const utils = api.useUtils();
+ const statusQuery = api.settings.getOnboardingStatus.useQuery();
+
+ const [businessName, setBusinessName] = useState("");
+ const [clientName, setClientName] = useState("");
+ const [step, setStep] = useState(0);
+
+ const createBusiness = api.businesses.create.useMutation();
+ const createClient = api.clients.create.useMutation();
+ const complete = api.settings.completeOnboarding.useMutation({
+ onSuccess: async () => {
+ await utils.settings.getOnboardingStatus.invalidate();
+ await utils.settings.getProfile.invalidate();
+ router.replace("/(app)");
+ },
+ });
+
+ async function finish() {
+ if (businessName.trim()) {
+ await createBusiness.mutateAsync({
+ name: businessName.trim(),
+ isDefault: true,
+ });
+ }
+ if (clientName.trim()) {
+ await createClient.mutateAsync({
+ name: clientName.trim(),
+ currency: "USD",
+ });
+ }
+ await complete.mutateAsync();
+ }
+
+ const steps = [
+ {
+ title: "Welcome to beenvoice",
+ body: "Set up your workspace in a minute — business profile and first client.",
+ },
+ {
+ title: "Your business",
+ body: "This appears on invoices you send to clients.",
+ },
+ {
+ title: "First client",
+ body: "Add someone you bill. You can skip and add clients later.",
+ },
+ ];
+
+ const current = steps[step]!;
+
+ return (
+
+
+
+ Step {step + 1} of {steps.length}
+
+ {current.title}
+ {current.body}
+
+ {step === 1 ? (
+
+ ) : null}
+
+ {step === 2 ? (
+
+ ) : null}
+
+
+ {step > 0 ? (
+
+
+ {statusQuery.data && !statusQuery.data.completed ? (
+ void complete.mutateAsync()} />
+ ) : null}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ body: {
+ padding: spacing.lg,
+ gap: spacing.md,
+ minHeight: "100%",
+ justifyContent: "center",
+ },
+ kicker: {
+ fontFamily: fonts.bodyMedium,
+ fontSize: 13,
+ },
+ title: {
+ fontFamily: fonts.heading,
+ fontSize: 32,
+ lineHeight: 36,
+ },
+ bodyText: {
+ fontFamily: fonts.body,
+ fontSize: 16,
+ lineHeight: 24,
+ },
+ actions: {
+ gap: spacing.sm,
+ marginTop: spacing.lg,
+ },
+});
diff --git a/components/OnboardingGate.tsx b/components/OnboardingGate.tsx
new file mode 100644
index 0000000..c73d5c6
--- /dev/null
+++ b/components/OnboardingGate.tsx
@@ -0,0 +1,19 @@
+import { router } from "expo-router";
+import { useEffect, useRef } from "react";
+
+import { api } from "@/lib/trpc";
+
+/** Redirect new users into onboarding until they complete setup. */
+export function OnboardingGate() {
+ const statusQuery = api.settings.getOnboardingStatus.useQuery();
+ const redirected = useRef(false);
+
+ useEffect(() => {
+ if (redirected.current || statusQuery.isLoading || !statusQuery.data) return;
+ if (statusQuery.data.completed) return;
+ redirected.current = true;
+ router.push("/(app)/onboarding" as never);
+ }, [statusQuery.data, statusQuery.isLoading]);
+
+ return null;
+}