@pylonsync/create-pylon 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/bin/create-pylon.js +10 -2
  2. package/package.json +1 -1
  3. package/templates/backend/mobile/apps/api/.env.example +22 -0
  4. package/templates/backend/mobile/apps/api/README.md +31 -0
  5. package/templates/backend/mobile/apps/api/app.ts +89 -0
  6. package/templates/backend/mobile/apps/api/functions/_pylonRcUpsertEntitlement.ts +1 -0
  7. package/templates/backend/mobile/apps/api/functions/createNote.ts +44 -0
  8. package/templates/backend/mobile/apps/api/functions/revenuecatWebhook.ts +1 -0
  9. package/templates/backend/mobile/apps/api/functions/syncEntitlements.ts +1 -0
  10. package/templates/backend/mobile/apps/api/lib/purchases.ts +22 -0
  11. package/templates/backend/mobile/apps/api/package.json +23 -0
  12. package/templates/backend/mobile/apps/api/tsconfig.json +13 -0
  13. package/templates/expo/mobile/apps/expo/.env.example +19 -0
  14. package/templates/expo/mobile/apps/expo/README.md +34 -0
  15. package/templates/expo/mobile/apps/expo/STORE.md +82 -0
  16. package/templates/expo/mobile/apps/expo/app/(auth)/_layout.tsx +6 -0
  17. package/templates/expo/mobile/apps/expo/app/(auth)/sign-in.tsx +194 -0
  18. package/templates/expo/mobile/apps/expo/app/(auth)/verify.tsx +78 -0
  19. package/templates/expo/mobile/apps/expo/app/(onboarding)/_layout.tsx +6 -0
  20. package/templates/expo/mobile/apps/expo/app/(onboarding)/welcome.tsx +125 -0
  21. package/templates/expo/mobile/apps/expo/app/(tabs)/_layout.tsx +27 -0
  22. package/templates/expo/mobile/apps/expo/app/(tabs)/index.tsx +128 -0
  23. package/templates/expo/mobile/apps/expo/app/(tabs)/settings.tsx +122 -0
  24. package/templates/expo/mobile/apps/expo/app/_layout.tsx +61 -0
  25. package/templates/expo/mobile/apps/expo/app/index.tsx +15 -0
  26. package/templates/expo/mobile/apps/expo/app/paywall.tsx +177 -0
  27. package/templates/expo/mobile/apps/expo/app.config.ts +70 -0
  28. package/templates/expo/mobile/apps/expo/assets/adaptive-icon.png +0 -0
  29. package/templates/expo/mobile/apps/expo/assets/icon.png +0 -0
  30. package/templates/expo/mobile/apps/expo/assets/splash-icon.png +0 -0
  31. package/templates/expo/mobile/apps/expo/babel.config.js +8 -0
  32. package/templates/expo/mobile/apps/expo/eas.json +45 -0
  33. package/templates/expo/mobile/apps/expo/package.json +52 -0
  34. package/templates/expo/mobile/apps/expo/src/analytics.ts +25 -0
  35. package/templates/expo/mobile/apps/expo/src/entitlements.ts +21 -0
  36. package/templates/expo/mobile/apps/expo/src/flags.ts +31 -0
  37. package/templates/expo/mobile/apps/expo/src/purchases.ts +164 -0
  38. package/templates/expo/mobile/apps/expo/src/pylon.ts +23 -0
  39. package/templates/expo/mobile/apps/expo/src/session.tsx +101 -0
  40. package/templates/expo/mobile/apps/expo/src/theme.ts +36 -0
  41. package/templates/expo/mobile/apps/expo/src/ui.tsx +187 -0
  42. package/templates/expo/mobile/apps/expo/tsconfig.json +10 -0
  43. package/templates/saas/.env.example +2 -0
  44. package/templates/saas/README.md +12 -8
  45. package/templates/saas/app/(auth)/auth-form.tsx +136 -95
  46. package/templates/saas/app/(auth)/forgot-password/page.tsx +20 -0
  47. package/templates/saas/app/(auth)/recovery-forms.tsx +135 -0
  48. package/templates/saas/app/(auth)/reset-password/page.tsx +24 -0
  49. package/templates/saas/app/(marketing)/layout.tsx +1 -1
  50. package/templates/saas/app/(marketing)/page.tsx +3 -52
  51. package/templates/saas/app/(marketing)/pricing/page.tsx +43 -0
  52. package/templates/saas/app/dashboard/dashboard-client.tsx +368 -18
  53. package/templates/saas/app/dashboard/page.tsx +30 -2
  54. package/templates/saas/app/dashboard/provision-workspace.tsx +10 -8
  55. package/templates/saas/app/dashboard/settings/page.tsx +3 -0
  56. package/templates/saas/app/onboarding/onboarding-client.tsx +250 -0
  57. package/templates/saas/app/onboarding/page.tsx +34 -0
  58. package/templates/saas/app/sitemap.ts +1 -0
  59. package/templates/saas/app.ts +18 -10
  60. package/templates/saas/components/pricing-table.tsx +97 -0
  61. package/templates/saas/components/setup-checklist.tsx +83 -0
  62. package/templates/saas/functions/completeOnboarding.ts +15 -0
  63. package/templates/saas/functions/createProject.ts +56 -0
  64. package/templates/saas/functions/dismissSetup.ts +14 -0
  65. package/templates/saas/functions/updateProfile.ts +18 -0
  66. package/templates/saas/lib/billing.ts +10 -3
  67. package/templates/saas/lib/plans.ts +94 -0
  68. package/templates/saas/lib/site.config.ts +13 -45
  69. package/templates/saas/tests/plans.test.ts +35 -0
  70. package/templates/saas/app/not-found.tsx +0 -44
@@ -0,0 +1,78 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { Alert, TextInput } from "react-native";
3
+ import { useLocalSearchParams, useRouter } from "expo-router";
4
+ import { sendEmailCode, verifyEmailCode } from "@pylonsync/react-native";
5
+ import { track } from "@/analytics";
6
+ import { space } from "@/theme";
7
+ import { Body, Button, Field, Screen, Spacer, Title } from "@/ui";
8
+
9
+ /** Enter the 6-digit code that was emailed. Auto-submits on the sixth digit. */
10
+ export default function Verify() {
11
+ const router = useRouter();
12
+ const { email, next } = useLocalSearchParams<{ email: string; next?: string }>();
13
+ const [code, setCode] = useState("");
14
+ const [busy, setBusy] = useState(false);
15
+ const [resent, setResent] = useState(false);
16
+ const input = useRef<TextInput>(null);
17
+
18
+ useEffect(() => {
19
+ const id = setTimeout(() => input.current?.focus(), 300);
20
+ return () => clearTimeout(id);
21
+ }, []);
22
+
23
+ async function submit(value: string) {
24
+ if (value.length !== 6 || busy) return;
25
+ setBusy(true);
26
+ try {
27
+ await verifyEmailCode(email, value);
28
+ track("sign_in_completed", { method: "email" });
29
+ if (next) router.replace(next as never);
30
+ else router.replace("/(tabs)");
31
+ } catch (e) {
32
+ setCode("");
33
+ Alert.alert("That code didn't work", e instanceof Error ? e.message : "Try again.");
34
+ } finally {
35
+ setBusy(false);
36
+ }
37
+ }
38
+
39
+ async function resend() {
40
+ try {
41
+ await sendEmailCode(email);
42
+ setResent(true);
43
+ } catch (e) {
44
+ Alert.alert("Couldn't resend", e instanceof Error ? e.message : String(e));
45
+ }
46
+ }
47
+
48
+ return (
49
+ <Screen>
50
+ <Spacer h={space.xxl} />
51
+ <Title>Check your email</Title>
52
+ <Body muted style={{ marginTop: space.sm }}>
53
+ We sent a 6-digit code to {email}.
54
+ </Body>
55
+ <Spacer h={space.xxl} />
56
+ <Field
57
+ ref={input}
58
+ placeholder="123456"
59
+ keyboardType="number-pad"
60
+ textContentType="oneTimeCode"
61
+ autoComplete="one-time-code"
62
+ maxLength={6}
63
+ value={code}
64
+ onChangeText={(v) => {
65
+ const digits = v.replace(/\D/g, "");
66
+ setCode(digits);
67
+ if (digits.length === 6) void submit(digits);
68
+ }}
69
+ style={{ fontSize: 24, letterSpacing: 8, textAlign: "center" }}
70
+ />
71
+ <Spacer />
72
+ <Button title="Continue" loading={busy} disabled={code.length !== 6} onPress={() => void submit(code)} />
73
+ <Spacer h={space.sm} />
74
+ <Button title={resent ? "Code sent again" : "Resend code"} variant="ghost" disabled={resent} onPress={() => void resend()} />
75
+ <Button title="Use a different email" variant="ghost" onPress={() => router.back()} />
76
+ </Screen>
77
+ );
78
+ }
@@ -0,0 +1,6 @@
1
+ import React from "react";
2
+ import { Stack } from "expo-router";
3
+
4
+ export default function OnboardingLayout() {
5
+ return <Stack screenOptions={{ headerShown: false, animation: "fade" }} />;
6
+ }
@@ -0,0 +1,125 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { Dimensions, FlatList, Pressable, Text, View, type ViewToken } from "react-native";
3
+ import { useRouter } from "expo-router";
4
+ import { track } from "@/analytics";
5
+ import { setPaywallSeenAt } from "@/flags";
6
+ import { useAppSession } from "@/session";
7
+ import { space, useTheme } from "@/theme";
8
+ import { Body, Button, Screen, Title } from "@/ui";
9
+
10
+ /**
11
+ * Three value slides, then "Get started". Replace the copy with your own;
12
+ * keep it to one outcome per slide. The last tap creates a guest session
13
+ * (no sign-up wall) and opens the paywall once, then lands in the app.
14
+ */
15
+ const SLIDES = [
16
+ {
17
+ title: "Capture anything, instantly",
18
+ body: "Notes sync to every device the moment you type them. Works offline.",
19
+ },
20
+ {
21
+ title: "Built for speed",
22
+ body: "No accounts to create before you start. Sign in later to keep everything.",
23
+ },
24
+ {
25
+ title: "Go Pro when you're ready",
26
+ body: "Unlimited notes and everything we ship next. Cancel any time.",
27
+ },
28
+ ];
29
+
30
+ const { width } = Dimensions.get("window");
31
+
32
+ export default function Welcome() {
33
+ const router = useRouter();
34
+ const t = useTheme();
35
+ const { completeOnboarding } = useAppSession();
36
+ const [index, setIndex] = useState(0);
37
+ const [busy, setBusy] = useState(false);
38
+ const list = useRef<FlatList>(null);
39
+
40
+ useEffect(() => {
41
+ track("onboarding_started");
42
+ }, []);
43
+
44
+ const onViewable = useRef(({ viewableItems }: { viewableItems: ViewToken[] }) => {
45
+ const first = viewableItems[0]?.index;
46
+ if (typeof first === "number") setIndex(first);
47
+ }).current;
48
+
49
+ async function finish() {
50
+ setBusy(true);
51
+ try {
52
+ await completeOnboarding();
53
+ track("onboarding_completed");
54
+ await setPaywallSeenAt();
55
+ // Show the paywall once, right after onboarding: the highest-intent
56
+ // moment. It is dismissible; the free tier keeps working.
57
+ router.replace("/(tabs)");
58
+ router.push("/paywall");
59
+ } finally {
60
+ setBusy(false);
61
+ }
62
+ }
63
+
64
+ const last = index === SLIDES.length - 1;
65
+
66
+ return (
67
+ <Screen padded={false}>
68
+ <View style={{ alignItems: "flex-end", paddingHorizontal: space.xl, minHeight: 44 }}>
69
+ {!last && (
70
+ <Pressable onPress={finish} hitSlop={12} accessibilityRole="button">
71
+ <Text style={{ color: t.muted, fontSize: 15 }}>Skip</Text>
72
+ </Pressable>
73
+ )}
74
+ </View>
75
+ <FlatList
76
+ ref={list}
77
+ data={SLIDES}
78
+ keyExtractor={(s) => s.title}
79
+ horizontal
80
+ pagingEnabled
81
+ showsHorizontalScrollIndicator={false}
82
+ onViewableItemsChanged={onViewable}
83
+ viewabilityConfig={{ itemVisiblePercentThreshold: 60 }}
84
+ renderItem={({ item }) => (
85
+ <View style={{ width, paddingHorizontal: space.xl, justifyContent: "center" }}>
86
+ <View
87
+ style={{
88
+ height: 220,
89
+ borderRadius: 24,
90
+ backgroundColor: t.surface,
91
+ marginBottom: space.xxl,
92
+ }}
93
+ />
94
+ <Title>{item.title}</Title>
95
+ <Body muted style={{ marginTop: space.md }}>
96
+ {item.body}
97
+ </Body>
98
+ </View>
99
+ )}
100
+ />
101
+ <View style={{ paddingHorizontal: space.xl, paddingBottom: space.lg, gap: space.lg }}>
102
+ <View style={{ flexDirection: "row", justifyContent: "center", gap: 6 }}>
103
+ {SLIDES.map((s, i) => (
104
+ <View
105
+ key={s.title}
106
+ style={{
107
+ width: i === index ? 18 : 6,
108
+ height: 6,
109
+ borderRadius: 3,
110
+ backgroundColor: i === index ? t.text : t.border,
111
+ }}
112
+ />
113
+ ))}
114
+ </View>
115
+ <Button
116
+ title={last ? "Get started" : "Next"}
117
+ loading={busy}
118
+ onPress={() =>
119
+ last ? void finish() : list.current?.scrollToIndex({ index: index + 1, animated: true })
120
+ }
121
+ />
122
+ </View>
123
+ </Screen>
124
+ );
125
+ }
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+ import { Text } from "react-native";
3
+ import { Tabs } from "expo-router";
4
+ import { useTheme } from "@/theme";
5
+
6
+ export default function TabsLayout() {
7
+ const t = useTheme();
8
+ return (
9
+ <Tabs
10
+ screenOptions={{
11
+ headerShown: false,
12
+ tabBarActiveTintColor: t.text,
13
+ tabBarInactiveTintColor: t.muted,
14
+ tabBarStyle: { backgroundColor: t.bg, borderTopColor: t.border },
15
+ }}
16
+ >
17
+ <Tabs.Screen
18
+ name="index"
19
+ options={{ title: "Notes", tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 18 }}>▤</Text> }}
20
+ />
21
+ <Tabs.Screen
22
+ name="settings"
23
+ options={{ title: "Settings", tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 18 }}>⚙</Text> }}
24
+ />
25
+ </Tabs>
26
+ );
27
+ }
@@ -0,0 +1,128 @@
1
+ import React, { useState } from "react";
2
+ import { Alert, FlatList, Pressable, Text, View } from "react-native";
3
+ import { useRouter } from "expo-router";
4
+ import { callFn, db } from "@pylonsync/react-native";
5
+ import { track } from "@/analytics";
6
+ import { usePro } from "@/entitlements";
7
+ import { useAppSession } from "@/session";
8
+ import { radius, space, useTheme } from "@/theme";
9
+ import { Body, Button, Caption, Field, Screen, Title } from "@/ui";
10
+
11
+ interface Note {
12
+ id: string;
13
+ ownerId: string;
14
+ title: string;
15
+ body?: string;
16
+ createdAt: string;
17
+ }
18
+
19
+ const FREE_LIMIT = 10;
20
+
21
+ /**
22
+ * The app's main screen: a live, offline-capable list. Reads come from the
23
+ * local replica (instant on cold start), creates go through `createNote`
24
+ * so the server enforces the free-tier cap, deletes are optimistic.
25
+ */
26
+ export default function Notes() {
27
+ const router = useRouter();
28
+ const t = useTheme();
29
+ const { isGuest } = useAppSession();
30
+ const { pro } = usePro();
31
+ const { data: notes = [], loading } = db.useQuery<Note>("Note", { orderBy: { createdAt: "desc" } });
32
+ const [draft, setDraft] = useState("");
33
+ const [saving, setSaving] = useState(false);
34
+
35
+ async function add() {
36
+ const title = draft.trim();
37
+ if (!title) return;
38
+ setSaving(true);
39
+ try {
40
+ await callFn("createNote", { title });
41
+ setDraft("");
42
+ } catch (e) {
43
+ const code = (e as { code?: string })?.code ?? "";
44
+ const msg = e instanceof Error ? e.message : String(e);
45
+ if (code === "LIMIT_REACHED" || /LIMIT_REACHED/.test(msg)) {
46
+ track("limit_reached");
47
+ router.push({ pathname: "/paywall", params: { reason: "limit" } });
48
+ } else {
49
+ Alert.alert("Couldn't save", msg);
50
+ }
51
+ } finally {
52
+ setSaving(false);
53
+ }
54
+ }
55
+
56
+ async function remove(note: Note) {
57
+ try {
58
+ await db.delete("Note", note.id);
59
+ } catch (e) {
60
+ Alert.alert("Couldn't delete", e instanceof Error ? e.message : String(e));
61
+ }
62
+ }
63
+
64
+ const remaining = Math.max(0, FREE_LIMIT - notes.length);
65
+
66
+ return (
67
+ <Screen>
68
+ <View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingVertical: space.md }}>
69
+ <Title>Notes</Title>
70
+ {pro ? (
71
+ <View style={{ backgroundColor: t.text, borderRadius: radius.pill, paddingHorizontal: 10, paddingVertical: 4 }}>
72
+ <Text style={{ color: t.bg, fontSize: 12, fontWeight: "700" }}>PRO</Text>
73
+ </View>
74
+ ) : (
75
+ <Pressable onPress={() => router.push("/paywall")} accessibilityRole="button">
76
+ <Caption>{remaining} free left · Upgrade</Caption>
77
+ </Pressable>
78
+ )}
79
+ </View>
80
+
81
+ <View style={{ flexDirection: "row", gap: space.sm }}>
82
+ <Field
83
+ placeholder="New note"
84
+ value={draft}
85
+ onChangeText={setDraft}
86
+ onSubmitEditing={() => void add()}
87
+ returnKeyType="done"
88
+ style={{ flex: 1 }}
89
+ />
90
+ <Button title="Add" loading={saving} disabled={!draft.trim()} onPress={() => void add()} style={{ paddingHorizontal: space.lg }} />
91
+ </View>
92
+
93
+ {isGuest && notes.length >= 3 ? (
94
+ <Pressable
95
+ onPress={() => router.push("/(auth)/sign-in")}
96
+ style={{ marginTop: space.md, padding: space.md, borderRadius: radius.md, backgroundColor: t.surface }}
97
+ >
98
+ <Caption>Sign in to keep these notes if you switch phones. ›</Caption>
99
+ </Pressable>
100
+ ) : null}
101
+
102
+ <FlatList
103
+ data={notes}
104
+ keyExtractor={(n) => n.id}
105
+ contentContainerStyle={{ paddingVertical: space.lg, gap: space.sm }}
106
+ ListEmptyComponent={
107
+ <View style={{ paddingTop: space.xxl, alignItems: "center", gap: space.sm }}>
108
+ <Body muted>{loading ? "Loading…" : "Nothing here yet. Add your first note above."}</Body>
109
+ </View>
110
+ }
111
+ renderItem={({ item }) => (
112
+ <Pressable
113
+ onLongPress={() =>
114
+ Alert.alert("Delete note?", item.title, [
115
+ { text: "Cancel", style: "cancel" },
116
+ { text: "Delete", style: "destructive", onPress: () => void remove(item) },
117
+ ])
118
+ }
119
+ style={{ padding: space.lg, borderRadius: radius.md, backgroundColor: t.surface }}
120
+ >
121
+ <Body>{item.title}</Body>
122
+ <Caption>{new Date(item.createdAt).toLocaleDateString()}</Caption>
123
+ </Pressable>
124
+ )}
125
+ />
126
+ </Screen>
127
+ );
128
+ }
@@ -0,0 +1,122 @@
1
+ import React, { useState } from "react";
2
+ import { Alert, Linking, ScrollView, View } from "react-native";
3
+ import { useRouter } from "expo-router";
4
+ import Constants from "expo-constants";
5
+ import { deleteAccount } from "@pylonsync/react-native";
6
+ import { track } from "@/analytics";
7
+ import { usePro } from "@/entitlements";
8
+ import { resetFlags } from "@/flags";
9
+ import { manageSubscriptionUrl, restore } from "@/purchases";
10
+ import { useAppSession } from "@/session";
11
+ import { space } from "@/theme";
12
+ import { Caption, Row, Screen, Spacer, Title } from "@/ui";
13
+
14
+ /**
15
+ * Account, subscription, legal, and support. App Review requires an
16
+ * in-app account deletion path when the app offers account creation, and
17
+ * both stores require the privacy policy and terms links.
18
+ */
19
+ export default function Settings() {
20
+ const router = useRouter();
21
+ const { userId, isGuest, signOut } = useAppSession();
22
+ const { pro } = usePro();
23
+ const [busy, setBusy] = useState<string | null>(null);
24
+
25
+ const privacy = process.env.EXPO_PUBLIC_PRIVACY_URL;
26
+ const terms = process.env.EXPO_PUBLIC_TERMS_URL;
27
+ const support = process.env.EXPO_PUBLIC_SUPPORT_EMAIL;
28
+ const version = Constants.expoConfig?.version ?? "dev";
29
+
30
+ async function restorePurchases() {
31
+ setBusy("restore");
32
+ try {
33
+ const ok = await restore();
34
+ Alert.alert(ok ? "Purchases restored" : "Nothing to restore");
35
+ } finally {
36
+ setBusy(null);
37
+ }
38
+ }
39
+
40
+ function confirmDelete() {
41
+ Alert.alert(
42
+ "Delete account?",
43
+ "This removes your account and every note on our servers. Subscriptions are managed by the store and must be cancelled there.",
44
+ [
45
+ { text: "Cancel", style: "cancel" },
46
+ {
47
+ text: "Delete",
48
+ style: "destructive",
49
+ onPress: () =>
50
+ void (async () => {
51
+ setBusy("delete");
52
+ try {
53
+ await deleteAccount();
54
+ track("account_deleted");
55
+ await resetFlags();
56
+ router.replace("/(onboarding)/welcome");
57
+ } catch (e) {
58
+ Alert.alert("Couldn't delete", e instanceof Error ? e.message : String(e));
59
+ } finally {
60
+ setBusy(null);
61
+ }
62
+ })(),
63
+ },
64
+ ],
65
+ );
66
+ }
67
+
68
+ return (
69
+ <Screen>
70
+ <ScrollView contentContainerStyle={{ paddingBottom: space.xxl }}>
71
+ <View style={{ paddingVertical: space.md }}>
72
+ <Title>Settings</Title>
73
+ </View>
74
+
75
+ <Caption>Account</Caption>
76
+ {isGuest ? (
77
+ <Row label="Sign in or create account" onPress={() => router.push("/(auth)/sign-in")} />
78
+ ) : (
79
+ <>
80
+ <Row label="Signed in" value={userId ?? ""} />
81
+ <Row label="Sign out" onPress={() => void signOut()} />
82
+ </>
83
+ )}
84
+ <Spacer />
85
+
86
+ <Caption>Subscription</Caption>
87
+ {pro ? (
88
+ <Row label="Plan" value="Pro" />
89
+ ) : (
90
+ <Row label="Upgrade to Pro" onPress={() => router.push("/paywall")} />
91
+ )}
92
+ {pro ? <Row label="Manage subscription" onPress={() => void Linking.openURL(manageSubscriptionUrl())} /> : null}
93
+ <Row label={busy === "restore" ? "Restoring…" : "Restore purchases"} onPress={() => void restorePurchases()} />
94
+ <Spacer />
95
+
96
+ <Caption>About</Caption>
97
+ {privacy ? <Row label="Privacy policy" onPress={() => void Linking.openURL(privacy)} /> : null}
98
+ {terms ? <Row label="Terms of service" onPress={() => void Linking.openURL(terms)} /> : null}
99
+ {support ? <Row label="Contact support" onPress={() => void Linking.openURL(`mailto:${support}`)} /> : null}
100
+ <Row label="Version" value={version} />
101
+ <Spacer />
102
+
103
+ {!isGuest ? (
104
+ <>
105
+ <Caption>Danger zone</Caption>
106
+ <Row label={busy === "delete" ? "Deleting…" : "Delete account"} danger onPress={confirmDelete} />
107
+ </>
108
+ ) : null}
109
+ {__DEV__ ? (
110
+ <>
111
+ <Spacer />
112
+ <Caption>Development</Caption>
113
+ <Row
114
+ label="Reset onboarding"
115
+ onPress={() => void resetFlags().then(() => router.replace("/(onboarding)/welcome"))}
116
+ />
117
+ </>
118
+ ) : null}
119
+ </ScrollView>
120
+ </Screen>
121
+ );
122
+ }
@@ -0,0 +1,61 @@
1
+ import React, { useEffect } from "react";
2
+ import { Stack, useRouter, useSegments } from "expo-router";
3
+ import { StatusBar } from "expo-status-bar";
4
+ import * as SplashScreen from "expo-splash-screen";
5
+ import { GestureHandlerRootView } from "react-native-gesture-handler";
6
+ import { SafeAreaProvider } from "react-native-safe-area-context";
7
+ import { SessionProvider, useAppSession } from "@/session";
8
+ import { useTheme } from "@/theme";
9
+
10
+ // Keep the native splash up until the session state is known, so the
11
+ // first frame is the right screen instead of a flash of the wrong one.
12
+ void SplashScreen.preventAutoHideAsync();
13
+
14
+ export default function RootLayout() {
15
+ return (
16
+ <GestureHandlerRootView style={{ flex: 1 }}>
17
+ <SafeAreaProvider>
18
+ <SessionProvider>
19
+ <Router />
20
+ </SessionProvider>
21
+ </SafeAreaProvider>
22
+ </GestureHandlerRootView>
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Route guard. The session state decides which group the user may be in:
28
+ * onboarding → (onboarding), signedOut → (auth), ready → (tabs). The
29
+ * paywall is a modal reachable from anywhere once ready.
30
+ */
31
+ function Router() {
32
+ const { state } = useAppSession();
33
+ const segments = useSegments();
34
+ const router = useRouter();
35
+ const t = useTheme();
36
+
37
+ useEffect(() => {
38
+ if (state === "booting") return;
39
+ void SplashScreen.hideAsync();
40
+ const group = segments[0];
41
+ if (state === "onboarding" && group !== "(onboarding)") {
42
+ router.replace("/(onboarding)/welcome");
43
+ } else if (state === "signedOut" && group !== "(auth)") {
44
+ router.replace("/(auth)/sign-in");
45
+ } else if (state === "ready" && (group === "(onboarding)" || group === undefined)) {
46
+ router.replace("/(tabs)");
47
+ }
48
+ }, [state, segments, router]);
49
+
50
+ return (
51
+ <>
52
+ <StatusBar style="auto" />
53
+ <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: t.bg } }}>
54
+ <Stack.Screen name="(onboarding)" />
55
+ <Stack.Screen name="(auth)" />
56
+ <Stack.Screen name="(tabs)" />
57
+ <Stack.Screen name="paywall" options={{ presentation: "modal", headerShown: false }} />
58
+ </Stack>
59
+ </>
60
+ );
61
+ }
@@ -0,0 +1,15 @@
1
+ import React from "react";
2
+ import { ActivityIndicator } from "react-native";
3
+ import { Centered, Screen } from "@/ui";
4
+
5
+ // "/" renders only while the session boots; the root layout replaces it
6
+ // with the right group as soon as the state is known.
7
+ export default function Index() {
8
+ return (
9
+ <Screen>
10
+ <Centered>
11
+ <ActivityIndicator />
12
+ </Centered>
13
+ </Screen>
14
+ );
15
+ }