@pylonsync/create-pylon 0.9.0 → 0.9.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 (37) hide show
  1. package/package.json +2 -2
  2. package/templates/_root/gitignore +1 -0
  3. package/templates/backend/mobile/apps/api/README.md +21 -1
  4. package/templates/backend/mobile/apps/api/app/(site)/layout.tsx +103 -0
  5. package/templates/backend/mobile/apps/api/app/(site)/not-found.tsx +23 -0
  6. package/templates/backend/mobile/apps/api/app/(site)/page.tsx +221 -0
  7. package/templates/backend/mobile/apps/api/app/(site)/privacy/page.tsx +131 -0
  8. package/templates/backend/mobile/apps/api/app/(site)/support/page.tsx +78 -0
  9. package/templates/backend/mobile/apps/api/app/(site)/terms/page.tsx +144 -0
  10. package/templates/backend/mobile/apps/api/app/error.tsx +27 -0
  11. package/templates/backend/mobile/apps/api/app/globals.css +87 -0
  12. package/templates/backend/mobile/apps/api/app/layout.tsx +27 -0
  13. package/templates/backend/mobile/apps/api/app/robots.ts +13 -0
  14. package/templates/backend/mobile/apps/api/app/sitemap.ts +16 -0
  15. package/templates/backend/mobile/apps/api/app.ts +11 -3
  16. package/templates/backend/mobile/apps/api/components/legal.tsx +64 -0
  17. package/templates/backend/mobile/apps/api/functions/createNote.ts +5 -1
  18. package/templates/backend/mobile/apps/api/functions/deleteMyData.ts +38 -0
  19. package/templates/backend/mobile/apps/api/lib/purchases.ts +8 -0
  20. package/templates/backend/mobile/apps/api/lib/site.ts +94 -0
  21. package/templates/backend/mobile/apps/api/package.json +8 -1
  22. package/templates/backend/mobile/apps/api/tsconfig.json +6 -2
  23. package/templates/expo/mobile/apps/expo/.env.example +13 -4
  24. package/templates/expo/mobile/apps/expo/README.md +35 -2
  25. package/templates/expo/mobile/apps/expo/STORE.md +31 -7
  26. package/templates/expo/mobile/apps/expo/app/(auth)/sign-in.tsx +17 -6
  27. package/templates/expo/mobile/apps/expo/app/(auth)/verify.tsx +3 -1
  28. package/templates/expo/mobile/apps/expo/app/(tabs)/settings.tsx +15 -7
  29. package/templates/expo/mobile/apps/expo/app/paywall.tsx +9 -13
  30. package/templates/expo/mobile/apps/expo/app.config.ts +29 -5
  31. package/templates/expo/mobile/apps/expo/babel.config.js +2 -2
  32. package/templates/expo/mobile/apps/expo/eas.json +6 -5
  33. package/templates/expo/mobile/apps/expo/package.json +9 -5
  34. package/templates/expo/mobile/apps/expo/src/entitlements.ts +4 -9
  35. package/templates/expo/mobile/apps/expo/src/links.ts +29 -0
  36. package/templates/expo/mobile/apps/expo/src/pylon.ts +7 -2
  37. package/templates/expo/mobile/apps/expo/src/session.tsx +69 -23
@@ -15,9 +15,16 @@ src/session.tsx boot + state machine
15
15
  src/purchases.ts RevenueCat wrapper, safe in Expo Go
16
16
  src/entitlements.ts usePro() from the synced RcEntitlement rows
17
17
  src/analytics.ts funnel events; wire to your SDK in one place
18
+ src/links.ts privacy / terms / support URLs, served by apps/api
18
19
  STORE.md the submission checklist
19
20
  ```
20
21
 
22
+ The public website (landing page, `/privacy`, `/terms`, `/support`) is served
23
+ by the backend in `apps/api`, on the same host as the API. The links in
24
+ Settings and under the paywall point there with no configuration, which is
25
+ what makes the store requirement satisfiable from a fresh scaffold. Edit its
26
+ copy in `apps/api/lib/site.ts`.
27
+
21
28
  ## Run
22
29
 
23
30
  ```bash
@@ -26,8 +33,34 @@ bun run dev # Expo Go: everything except native sign-in and purchases
26
33
  eas build --profile development --platform ios && bun run dev # dev build: everything
27
34
  ```
28
35
 
29
- The backend must be running (`cd ../api && bun run dev`) or deployed
30
- (`EXPO_PUBLIC_PYLON_BASE_URL` in `.env`).
36
+ The backend must be running (`cd ../api && bun run dev`, port 4321) or
37
+ deployed (`EXPO_PUBLIC_PYLON_BASE_URL` in `.env`). An Android emulator
38
+ reaches the host machine at `http://10.0.2.2:4321`; `src/pylon.ts` uses
39
+ that when the variable is unset.
40
+
41
+ `bun run check` typechecks. `bun run check:bundle` exports the iOS and
42
+ Android bundles without a device; run it after changing dependencies.
43
+
44
+ Adding an Expo native module (a package with `ios/` or `android/`) needs a
45
+ new development build (`eas build --profile development`). The Metro
46
+ server can keep running.
47
+
48
+ ## Replace the demo
49
+
50
+ The Notes screens are placeholders. When you replace them, these files
51
+ also carry Notes-specific copy or logic:
52
+
53
+ - `app/(onboarding)/welcome.tsx`: the three slides.
54
+ - `app/(auth)/sign-in.tsx`: the line under the title.
55
+ - `app/paywall.tsx`: `BENEFITS` and the reason text.
56
+ - `app/(tabs)/settings.tsx`: the delete-account confirmation.
57
+ - `app/(tabs)/index.tsx`: the list, `FREE_LIMIT`, and the sign-in nudge.
58
+ - `app.config.ts`: `name`, `slug`, `scheme`, and the icons in `assets/`.
59
+ - `apps/api/lib/site.ts`: the website copy, store links, and the company
60
+ details the privacy policy and terms need.
61
+ - `apps/api/functions/deleteMyData.ts`: delete every entity that stores
62
+ user data, or account deletion leaves rows behind.
63
+ - `apps/api/functions/createNote.ts`: the free-tier cap.
31
64
 
32
65
  ## Ship
33
66
 
@@ -3,7 +3,7 @@
3
3
  The order below is the shortest path that App Review and Google Play accept
4
4
  on the first try. Each step is one command or one dashboard page.
5
5
 
6
- ## 1. Backend live
6
+ ## 1. Backend and website live
7
7
 
8
8
  ```bash
9
9
  cd apps/api && pylon deploy
@@ -11,12 +11,15 @@ pylon secrets set PYLON_APPLE_NATIVE_CLIENT_IDS=<bundle id> REVENUECAT_WEBHOOK_A
11
11
  ```
12
12
 
13
13
  Put the deployed URL in `apps/expo/eas.json` (`EXPO_PUBLIC_PYLON_BASE_URL`
14
- under `preview` and `production`).
14
+ under `preview` and `production`). `app.config.ts` refuses a preview or
15
+ production build until that value, `APP_BUNDLE_ID`, and `EAS_PROJECT_ID`
16
+ are set.
15
17
 
16
18
  ## 2. Identifiers
17
19
 
18
20
  - Pick a bundle id (`com.yourco.app`) and set `APP_BUNDLE_ID` in `eas.json`
19
- env for every profile, or export it before building.
21
+ env for `preview` and `production`, or export it before building. The
22
+ `development` profile appends `.dev` to the `com.example` placeholder.
20
23
  - `eas init` prints the project id; set `EAS_PROJECT_ID` the same way.
21
24
  - Apple: an App ID with the Sign in with Apple capability. EAS creates it on
22
25
  the first `eas build` when you let it manage credentials.
@@ -49,8 +52,12 @@ Test the purchase on TestFlight with a sandbox Apple ID before submitting.
49
52
 
50
53
  Both stores require, and reviewers check:
51
54
 
52
- - Privacy policy URL and terms URL (`EXPO_PUBLIC_PRIVACY_URL`, `EXPO_PUBLIC_TERMS_URL`).
53
- The app shows them in Settings and under the paywall.
55
+ - Privacy policy URL and terms URL. The backend serves them at
56
+ `<your-backend>/privacy` and `<your-backend>/terms`, and the app links to
57
+ them from Settings and under the paywall with no configuration. Fill in
58
+ `apps/api/lib/site.ts` first: the text ships as a draft and a banner stays
59
+ on the page until you do.
60
+ - A support URL for App Store Connect: `<your-backend>/support`.
54
61
  - Account deletion inside the app (Settings → Delete account). Present.
55
62
  - Sign in with Apple when any other third-party sign-in is offered. Present.
56
63
  - Subscription terms next to the purchase button (price, period, renewal). Present.
@@ -70,8 +77,25 @@ eas submit --platform ios --latest
70
77
  eas submit --platform android --latest
71
78
  ```
72
79
 
73
- Fill `submit.production.ios.ascAppId` in `eas.json` with the App Store
74
- Connect app id first.
80
+ For iOS, add the App Store Connect app id to `eas.json` first:
81
+
82
+ ```json
83
+ "submit": { "production": { "ios": { "ascAppId": "1234567890" } } }
84
+ ```
85
+
86
+ Without it `eas submit` asks for the app interactively.
87
+
88
+ ## Before the first store build
89
+
90
+ - Products: the `pro` entitlement and a `default` offering exist in
91
+ RevenueCat, and the iOS and Android public keys are in `apps/expo/.env`.
92
+ - Sign-in: `PYLON_APPLE_NATIVE_CLIENT_IDS` (the bundle id) and
93
+ `PYLON_GOOGLE_NATIVE_CLIENT_IDS` are set on the backend.
94
+ - Legal: `apps/api/lib/site.ts` is filled in, the draft banner is gone from
95
+ `/privacy` and `/terms`, and a lawyer has read both.
96
+ - Deletion: `apps/api/functions/deleteMyData.ts` removes every entity that
97
+ stores user data. Test it with a throwaway account.
98
+ - Copy: the "Replace the demo" list in `README.md` is done.
75
99
 
76
100
  ## After launch
77
101
 
@@ -1,7 +1,7 @@
1
1
  import React, { useEffect, useState } from "react";
2
2
  import { Alert, Platform, Text, View } from "react-native";
3
3
  import { useLocalSearchParams, useRouter } from "expo-router";
4
- import { nativeSignIn, sendEmailCode } from "@pylonsync/react-native";
4
+ import { db, nativeSignIn, sendEmailCode } from "@pylonsync/react-native";
5
5
  import { track } from "@/analytics";
6
6
  import { useAppSession } from "@/session";
7
7
  import { space, useTheme } from "@/theme";
@@ -59,12 +59,23 @@ export default function SignIn() {
59
59
  });
60
60
  }, [apple, google]);
61
61
 
62
- function done() {
62
+ async function done() {
63
63
  track("sign_in_completed");
64
+ // Wait for the server to confirm the new session so the route guard
65
+ // sees `ready` before the navigation, not after it.
66
+ await db.sync.notifySessionChanged();
64
67
  if (params.next) router.replace(params.next as never);
65
68
  else router.replace("/(tabs)");
66
69
  }
67
70
 
71
+ // "Not now" is reachable from a cold start (no history) as well as from
72
+ // Settings (history). Fall back to the app when there is nothing to go
73
+ // back to.
74
+ function dismiss() {
75
+ if (router.canGoBack()) router.back();
76
+ else router.replace("/(tabs)");
77
+ }
78
+
68
79
  async function withApple() {
69
80
  if (!apple) return;
70
81
  setBusy("apple");
@@ -81,7 +92,7 @@ export default function SignIn() {
81
92
  .filter(Boolean)
82
93
  .join(" ");
83
94
  await nativeSignIn("apple", credential.identityToken, name || undefined);
84
- done();
95
+ await done();
85
96
  } catch (e) {
86
97
  if ((e as { code?: string })?.code !== "ERR_REQUEST_CANCELED") {
87
98
  Alert.alert("Sign in failed", messageOf(e));
@@ -101,7 +112,7 @@ export default function SignIn() {
101
112
  const idToken = result.type === "success" ? result.data.idToken : null;
102
113
  if (!idToken) return;
103
114
  await nativeSignIn("google", idToken, result.type === "success" ? result.data.user.name : undefined);
104
- done();
115
+ await done();
105
116
  } catch (e) {
106
117
  Alert.alert("Sign in failed", messageOf(e));
107
118
  } finally {
@@ -132,7 +143,7 @@ export default function SignIn() {
132
143
  <Spacer h={space.xxl} />
133
144
  <Title>Sign in</Title>
134
145
  <Body muted style={{ marginTop: space.sm }}>
135
- Keep your notes on every device. Anything you made already comes with you.
146
+ Keep everything on every device. Anything you made already comes with you.
136
147
  </Body>
137
148
  <Spacer h={space.xxl} />
138
149
  <View style={{ gap: space.md }}>
@@ -179,7 +190,7 @@ export default function SignIn() {
179
190
  onPress={() => void continueAsGuest().then(() => router.replace("/(tabs)"))}
180
191
  />
181
192
  ) : (
182
- <Button title="Not now" variant="ghost" onPress={() => router.back()} />
193
+ <Button title="Not now" variant="ghost" onPress={dismiss} />
183
194
  )}
184
195
  <Spacer />
185
196
  <Text style={{ color: t.muted, fontSize: 12, textAlign: "center", lineHeight: 18 }}>
@@ -1,7 +1,7 @@
1
1
  import React, { useEffect, useRef, useState } from "react";
2
2
  import { Alert, TextInput } from "react-native";
3
3
  import { useLocalSearchParams, useRouter } from "expo-router";
4
- import { sendEmailCode, verifyEmailCode } from "@pylonsync/react-native";
4
+ import { db, sendEmailCode, verifyEmailCode } from "@pylonsync/react-native";
5
5
  import { track } from "@/analytics";
6
6
  import { space } from "@/theme";
7
7
  import { Body, Button, Field, Screen, Spacer, Title } from "@/ui";
@@ -26,6 +26,8 @@ export default function Verify() {
26
26
  try {
27
27
  await verifyEmailCode(email, value);
28
28
  track("sign_in_completed", { method: "email" });
29
+ // See sign-in.tsx: let the route guard observe the session first.
30
+ await db.sync.notifySessionChanged();
29
31
  if (next) router.replace(next as never);
30
32
  else router.replace("/(tabs)");
31
33
  } catch (e) {
@@ -4,6 +4,7 @@ import { useRouter } from "expo-router";
4
4
  import Constants from "expo-constants";
5
5
  import { deleteAccount } from "@pylonsync/react-native";
6
6
  import { track } from "@/analytics";
7
+ import { PRIVACY_URL, SUPPORT_EMAIL, SUPPORT_URL, TERMS_URL } from "@/links";
7
8
  import { usePro } from "@/entitlements";
8
9
  import { resetFlags } from "@/flags";
9
10
  import { manageSubscriptionUrl, restore } from "@/purchases";
@@ -15,6 +16,11 @@ import { Caption, Row, Screen, Spacer, Title } from "@/ui";
15
16
  * Account, subscription, legal, and support. App Review requires an
16
17
  * in-app account deletion path when the app offers account creation, and
17
18
  * both stores require the privacy policy and terms links.
19
+ *
20
+ * Delete account: `deleteAccount()` calls `DELETE /api/auth/account`. The
21
+ * backend runs `deleteMyData` first (see `auth({ onDeleteAccount })` in
22
+ * apps/api/app.ts), so the user's notes and entitlement rows go with the
23
+ * account. Add every new entity that stores user data to that function.
18
24
  */
19
25
  export default function Settings() {
20
26
  const router = useRouter();
@@ -22,9 +28,8 @@ export default function Settings() {
22
28
  const { pro } = usePro();
23
29
  const [busy, setBusy] = useState<string | null>(null);
24
30
 
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;
31
+ // These resolve to the site the backend serves (apps/api), so the rows
32
+ // always render and always lead somewhere real. See src/links.ts.
28
33
  const version = Constants.expoConfig?.version ?? "dev";
29
34
 
30
35
  async function restorePurchases() {
@@ -40,7 +45,7 @@ export default function Settings() {
40
45
  function confirmDelete() {
41
46
  Alert.alert(
42
47
  "Delete account?",
43
- "This removes your account and every note on our servers. Subscriptions are managed by the store and must be cancelled there.",
48
+ "This deletes your account and every note on our servers. A subscription is managed by the store; cancel it there.",
44
49
  [
45
50
  { text: "Cancel", style: "cancel" },
46
51
  {
@@ -94,9 +99,12 @@ export default function Settings() {
94
99
  <Spacer />
95
100
 
96
101
  <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}
102
+ <Row label="Help" onPress={() => void Linking.openURL(SUPPORT_URL)} />
103
+ <Row label="Privacy policy" onPress={() => void Linking.openURL(PRIVACY_URL)} />
104
+ <Row label="Terms of service" onPress={() => void Linking.openURL(TERMS_URL)} />
105
+ {SUPPORT_EMAIL ? (
106
+ <Row label="Contact support" onPress={() => void Linking.openURL(`mailto:${SUPPORT_EMAIL}`)} />
107
+ ) : null}
100
108
  <Row label="Version" value={version} />
101
109
  <Spacer />
102
110
 
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react";
2
2
  import { Alert, Linking, Pressable, Text, View } from "react-native";
3
3
  import { useLocalSearchParams, useRouter } from "expo-router";
4
4
  import { track } from "@/analytics";
5
+ import { PRIVACY_URL, TERMS_URL } from "@/links";
5
6
  import { usePro } from "@/entitlements";
6
7
  import { available, offers, purchase, restore, type Offer } from "@/purchases";
7
8
  import { radius, space, useTheme } from "@/theme";
@@ -45,8 +46,7 @@ export default function Paywall() {
45
46
  if (pro) router.back();
46
47
  }, [pro, router]);
47
48
 
48
- const privacy = process.env.EXPO_PUBLIC_PRIVACY_URL;
49
- const terms = process.env.EXPO_PUBLIC_TERMS_URL;
49
+
50
50
 
51
51
  async function buy() {
52
52
  const offer = list?.find((o) => o.id === selected);
@@ -160,17 +160,13 @@ export default function Paywall() {
160
160
  <Button title="Restore purchases" variant="ghost" loading={busy === "restore"} onPress={() => void restorePurchases()} />
161
161
  <Text style={{ color: t.muted, fontSize: 11, textAlign: "center", lineHeight: 16, marginTop: space.sm }}>
162
162
  Renews automatically until cancelled. Manage in your store account settings.{" "}
163
- {terms ? (
164
- <Text style={{ textDecorationLine: "underline" }} onPress={() => void Linking.openURL(terms)}>
165
- Terms
166
- </Text>
167
- ) : null}
168
- {terms && privacy ? " · " : ""}
169
- {privacy ? (
170
- <Text style={{ textDecorationLine: "underline" }} onPress={() => void Linking.openURL(privacy)}>
171
- Privacy
172
- </Text>
173
- ) : null}
163
+ <Text style={{ textDecorationLine: "underline" }} onPress={() => void Linking.openURL(TERMS_URL)}>
164
+ Terms
165
+ </Text>
166
+ {" · "}
167
+ <Text style={{ textDecorationLine: "underline" }} onPress={() => void Linking.openURL(PRIVACY_URL)}>
168
+ Privacy
169
+ </Text>
174
170
  </Text>
175
171
  </Screen>
176
172
  );
@@ -4,10 +4,11 @@ import type { ConfigContext, ExpoConfig } from "expo/config";
4
4
  * App config. The values that change per environment come from env vars
5
5
  * so one file serves development builds, TestFlight, and the store:
6
6
  *
7
- * APP_BUNDLE_ID reverse-DNS id (iOS bundle id + Android package)
8
- * APP_SCHEME deep-link scheme, e.g. "__APP_NAME_KEBAB__"
9
- * EAS_PROJECT_ID from `eas init` (printed once, then stable)
10
- * APP_VARIANT "development" | "preview" | "production" (set by eas.json)
7
+ * APP_BUNDLE_ID reverse-DNS id (iOS bundle id + Android package)
8
+ * APP_SCHEME deep-link scheme, e.g. "__APP_NAME_KEBAB__"
9
+ * EAS_PROJECT_ID from `eas init` (printed once, then stable)
10
+ * EXPO_PUBLIC_PYLON_BASE_URL the backend; https:// outside development
11
+ * APP_VARIANT "development" | "preview" | "production" (set by eas.json)
11
12
  *
12
13
  * A development build gets a ".dev" suffix so it installs alongside the
13
14
  * store app on the same phone.
@@ -17,6 +18,27 @@ const baseBundleId = process.env.APP_BUNDLE_ID ?? "com.example.__APP_NAME_SNAKE_
17
18
  const bundleId = variant === "development" ? `${baseBundleId}.dev` : baseBundleId;
18
19
  const name = variant === "production" ? "__APP_NAME__" : `__APP_NAME__ (${variant})`;
19
20
 
21
+ // A preview or store build with placeholder values would install and
22
+ // then fail at first launch. Refuse to build instead.
23
+ if (variant !== "development") {
24
+ const problems: string[] = [];
25
+ if (!process.env.APP_BUNDLE_ID) {
26
+ problems.push("APP_BUNDLE_ID is unset; the com.example placeholder cannot ship");
27
+ }
28
+ if (!process.env.EAS_PROJECT_ID) {
29
+ problems.push("EAS_PROJECT_ID is unset; run `eas init` and copy the id");
30
+ }
31
+ if (!/^https:\/\//.test(process.env.EXPO_PUBLIC_PYLON_BASE_URL ?? "")) {
32
+ problems.push("EXPO_PUBLIC_PYLON_BASE_URL must be the deployed https:// backend");
33
+ }
34
+ if (problems.length > 0) {
35
+ throw new Error(
36
+ `The ${variant} build is not configured:\n - ${problems.join("\n - ")}\n` +
37
+ `Set these under build.${variant}.env in eas.json, or export them before building.`,
38
+ );
39
+ }
40
+ }
41
+
20
42
  export default ({ config }: ConfigContext): ExpoConfig => ({
21
43
  ...config,
22
44
  name,
@@ -56,7 +78,9 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
56
78
  [
57
79
  "expo-build-properties",
58
80
  {
59
- ios: { deploymentTarget: "15.1" },
81
+ // The floors for Expo SDK 57 / React Native 0.86. expo-build-properties
82
+ // rejects a lower iOS target; Android below 24 does not build.
83
+ ios: { deploymentTarget: "16.4" },
60
84
  // Cleartext lets a dev/preview build reach http://localhost:4321.
61
85
  android: { minSdkVersion: 24, usesCleartextTraffic: variant !== "production" },
62
86
  },
@@ -1,8 +1,8 @@
1
1
  module.exports = function (api) {
2
2
  api.cache(true);
3
3
  return {
4
+ // babel-preset-expo 57 registers the react-native-worklets plugin
5
+ // (Reanimated 4) itself; adding it here again applies it twice.
4
6
  presets: ["babel-preset-expo"],
5
- // Reanimated's plugin must be last.
6
- plugins: ["react-native-reanimated/plugin"],
7
7
  };
8
8
  };
@@ -21,22 +21,23 @@
21
21
  "distribution": "internal",
22
22
  "env": {
23
23
  "APP_VARIANT": "preview",
24
- "EXPO_PUBLIC_PYLON_BASE_URL": "https://__APP_NAME_KEBAB__.pylonapp.com"
24
+ "APP_BUNDLE_ID": "",
25
+ "EAS_PROJECT_ID": "",
26
+ "EXPO_PUBLIC_PYLON_BASE_URL": ""
25
27
  }
26
28
  },
27
29
  "production": {
28
30
  "autoIncrement": true,
29
31
  "env": {
30
32
  "APP_VARIANT": "production",
31
- "EXPO_PUBLIC_PYLON_BASE_URL": "https://__APP_NAME_KEBAB__.pylonapp.com"
33
+ "APP_BUNDLE_ID": "",
34
+ "EAS_PROJECT_ID": "",
35
+ "EXPO_PUBLIC_PYLON_BASE_URL": ""
32
36
  }
33
37
  }
34
38
  },
35
39
  "submit": {
36
40
  "production": {
37
- "ios": {
38
- "ascAppId": "REPLACE_WITH_APP_STORE_CONNECT_APP_ID"
39
- },
40
41
  "android": {
41
42
  "track": "internal"
42
43
  }
@@ -14,11 +14,13 @@
14
14
  "build:preview": "eas build --profile preview --platform all",
15
15
  "build:prod": "eas build --profile production --platform all",
16
16
  "submit:ios": "eas submit --platform ios --latest",
17
- "submit:android": "eas submit --platform android --latest"
17
+ "submit:android": "eas submit --platform android --latest",
18
+ "check:bundle": "expo export --platform ios --platform android --output-dir .expo-export"
18
19
  },
19
20
  "dependencies": {
20
21
  "@pylonsync/react": "^__PYLON_VERSION__",
21
22
  "@pylonsync/react-native": "^__PYLON_VERSION__",
23
+ "@pylonsync/revenuecat": "^__PYLON_VERSION__",
22
24
  "@pylonsync/sdk": "^__PYLON_VERSION__",
23
25
  "@pylonsync/sync": "^__PYLON_VERSION__",
24
26
  "@react-native-async-storage/async-storage": "2.2.0",
@@ -42,11 +44,13 @@
42
44
  "react-native-reanimated": "4.5.1",
43
45
  "react-native-safe-area-context": "~5.7.0",
44
46
  "react-native-screens": "~4.26.0",
45
- "react-native-web": "~0.21.0"
47
+ "react-native-web": "~0.21.0",
48
+ "react-native-worklets": "0.10.1"
46
49
  },
47
50
  "devDependencies": {
48
- "@babel/core": "^7.25.0",
49
- "@types/react": "~19.2.0",
50
- "typescript": "~5.9.0"
51
+ "@babel/core": "^7.29.0",
52
+ "@types/react": "~19.2.4",
53
+ "babel-preset-expo": "~57.0.10",
54
+ "typescript": "~6.0.3"
51
55
  }
52
56
  }
@@ -1,19 +1,14 @@
1
1
  import { db } from "@pylonsync/react-native";
2
- import { hasEntitlement } from "@pylonsync/revenuecat";
2
+ import { hasEntitlement, type RcEntitlementRow } from "@pylonsync/revenuecat/client";
3
3
 
4
4
  export const PRO = "pro";
5
5
 
6
- interface RcEntitlementRow {
7
- id: string;
8
- userId: string;
9
- entitlement: string;
10
- status: string;
11
- expiresAt?: string | null;
12
- }
13
-
14
6
  /**
15
7
  * Live Pro status from the synced RcEntitlement rows. Updates the moment
16
8
  * the server writes the row after a purchase, on every device.
9
+ *
10
+ * `@pylonsync/revenuecat/client` is the browser/React Native entry of the
11
+ * plugin. The package root is server code and must not be imported here.
17
12
  */
18
13
  export function usePro(): { pro: boolean; loading: boolean } {
19
14
  const { data, loading } = db.useQuery<RcEntitlementRow>("RcEntitlement", {});
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Where the app sends people for legal text and help.
3
+ *
4
+ * The backend in `apps/api` serves a website as well as the API, so these
5
+ * pages already exist on whatever host `EXPO_PUBLIC_PYLON_BASE_URL` points
6
+ * at. That is what makes the App Store and Play Store requirements
7
+ * satisfiable straight from a scaffold: both stores refuse a submission
8
+ * without a reachable privacy policy URL.
9
+ *
10
+ * Set the EXPO_PUBLIC_* values only if you host the marketing site somewhere
11
+ * else, on your own domain for example.
12
+ */
13
+ import { PYLON_BASE_URL } from "./pylon";
14
+
15
+ const siteOrigin = (
16
+ process.env.EXPO_PUBLIC_SITE_URL || PYLON_BASE_URL
17
+ ).replace(/\/+$/, "");
18
+
19
+ export const PRIVACY_URL =
20
+ process.env.EXPO_PUBLIC_PRIVACY_URL || `${siteOrigin}/privacy`;
21
+
22
+ export const TERMS_URL =
23
+ process.env.EXPO_PUBLIC_TERMS_URL || `${siteOrigin}/terms`;
24
+
25
+ export const SUPPORT_URL =
26
+ process.env.EXPO_PUBLIC_SUPPORT_URL || `${siteOrigin}/support`;
27
+
28
+ /** Optional. Settings shows a "Contact support" row when it is set. */
29
+ export const SUPPORT_EMAIL = process.env.EXPO_PUBLIC_SUPPORT_EMAIL;
@@ -1,9 +1,14 @@
1
1
  import { Platform } from "react-native";
2
2
  import { init } from "@pylonsync/react-native";
3
3
 
4
- /** The backend URL. Android emulators reach the host machine at 10.0.2.2. */
4
+ /**
5
+ * The backend URL. `pylon dev` in apps/api listens on 4321. An Android
6
+ * emulator reaches the host machine at 10.0.2.2. Outside development
7
+ * app.config.ts refuses to build without an https:// value.
8
+ */
9
+ const configured = process.env.EXPO_PUBLIC_PYLON_BASE_URL?.trim();
5
10
  export const PYLON_BASE_URL =
6
- process.env.EXPO_PUBLIC_PYLON_BASE_URL ??
11
+ configured ||
7
12
  (Platform.OS === "android" ? "http://10.0.2.2:4321" : "http://localhost:4321");
8
13
 
9
14
  export const APP_NAME = "__APP_NAME_SNAKE__";
@@ -1,8 +1,15 @@
1
1
  import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
2
- import { db, guestSession, signOut as pylonSignOut, useSession } from "@pylonsync/react-native";
2
+ import {
3
+ db,
4
+ getReactStorage,
5
+ guestSession,
6
+ signOut as pylonSignOut,
7
+ storageKey,
8
+ useSession,
9
+ } from "@pylonsync/react-native";
3
10
  import { ensurePylon } from "./pylon";
4
11
  import { getOnboardingDone, setOnboardingDone as persistOnboardingDone } from "./flags";
5
- import { configure as configurePurchases } from "./purchases";
12
+ import { configure as configurePurchases, syncEntitlements } from "./purchases";
6
13
 
7
14
  /**
8
15
  * What the router needs to know:
@@ -10,7 +17,7 @@ import { configure as configurePurchases } from "./purchases";
10
17
  * booting the engine and the on-device flags are still loading
11
18
  * onboarding first launch: show the welcome slides
12
19
  * ready a session exists (guest or account) and the tabs can render
13
- * signedOut the user signed out explicitly; offer sign-in or a fresh guest
20
+ * signedOut no session; offer sign-in or a fresh guest
14
21
  */
15
22
  export type SessionState = "booting" | "onboarding" | "ready" | "signedOut";
16
23
 
@@ -26,37 +33,73 @@ interface SessionValue {
26
33
 
27
34
  const Ctx = createContext<SessionValue | null>(null);
28
35
 
36
+ async function notBooted(): Promise<void> {
37
+ throw new Error("Pylon is still booting");
38
+ }
39
+
40
+ const BOOTING: SessionValue = {
41
+ state: "booting",
42
+ userId: null,
43
+ isGuest: false,
44
+ completeOnboarding: notBooted,
45
+ continueAsGuest: notBooted,
46
+ signOut: notBooted,
47
+ };
48
+
49
+ /**
50
+ * Boots Pylon, then mounts the session reader. The order matters: before
51
+ * `init()` has run, `db.sync` would create a default engine with the
52
+ * wrong base URL and no AsyncStorage, and the reader would stay bound to
53
+ * it. Children render during boot, so the native splash stays up (see
54
+ * app/_layout.tsx) instead of a blank frame.
55
+ */
29
56
  export function SessionProvider({ children }: { children: React.ReactNode }) {
30
- const [booted, setBooted] = useState(false);
31
- const [onboardingDone, setOnboardingDoneState] = useState(false);
32
- const [signedOut, setSignedOut] = useState(false);
33
- const session = useSession(db.sync);
34
- const userId = session.userId;
57
+ const [boot, setBoot] = useState<{ onboardingDone: boolean } | null>(null);
35
58
 
36
59
  useEffect(() => {
37
60
  let alive = true;
38
61
  void (async () => {
39
62
  await ensurePylon();
40
- const done = await getOnboardingDone();
41
- if (!alive) return;
42
- setOnboardingDoneState(done);
43
- setBooted(true);
63
+ const onboardingDone = await getOnboardingDone();
64
+ if (alive) setBoot({ onboardingDone });
44
65
  })();
45
66
  return () => {
46
67
  alive = false;
47
68
  };
48
69
  }, []);
49
70
 
71
+ if (!boot) return <Ctx.Provider value={BOOTING}>{children}</Ctx.Provider>;
72
+ return <BootedSession initialOnboardingDone={boot.onboardingDone}>{children}</BootedSession>;
73
+ }
74
+
75
+ /** A token in AsyncStorage: the last session (guest or account) to restore. */
76
+ function hasStoredToken(): boolean {
77
+ return Boolean(getReactStorage().get(storageKey("token")));
78
+ }
79
+
80
+ function BootedSession({
81
+ initialOnboardingDone,
82
+ children,
83
+ }: {
84
+ initialOnboardingDone: boolean;
85
+ children: React.ReactNode;
86
+ }) {
87
+ const [onboardingDone, setOnboardingDoneState] = useState(initialOnboardingDone);
88
+ const session = useSession(db.sync);
89
+ const userId = session.userId;
90
+
50
91
  // Identify the purchase SDK with the Pylon user id so RevenueCat's
51
- // app_user_id is our id and webhook events map to our rows.
92
+ // app_user_id is our id and webhook events map to our rows. After a
93
+ // guest signs in, RevenueCat aliases the ids; re-reading the
94
+ // entitlements moves the guest's purchase onto the account.
52
95
  useEffect(() => {
53
- if (userId) void configurePurchases(userId);
96
+ if (!userId) return;
97
+ void configurePurchases(userId).then((ok) => (ok ? syncEntitlements() : undefined));
54
98
  }, [userId]);
55
99
 
56
100
  const continueAsGuest = useCallback(async () => {
57
101
  await guestSession();
58
102
  await db.sync.notifySessionChanged();
59
- setSignedOut(false);
60
103
  }, []);
61
104
 
62
105
  const completeOnboarding = useCallback(async () => {
@@ -69,16 +112,19 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
69
112
 
70
113
  const signOut = useCallback(async () => {
71
114
  await pylonSignOut();
72
- setSignedOut(true);
115
+ // Wait for /api/auth/me so `state` is signedOut before the caller
116
+ // navigates, not one render later.
117
+ await db.sync.notifySessionChanged();
73
118
  }, []);
74
119
 
75
- const state: SessionState = !booted
76
- ? "booting"
77
- : !onboardingDone
78
- ? "onboarding"
79
- : userId && !signedOut
80
- ? "ready"
81
- : "signedOut";
120
+ // Until /api/auth/me answers, `session.userId` is null because the
121
+ // answer has not arrived, not because the user is signed out. A stored
122
+ // token means the previous session is still the one to show: the
123
+ // replica renders from AsyncStorage and the server confirms or rejects
124
+ // the token on the first pull. An expired token resolves to no user and
125
+ // the state moves to signedOut then.
126
+ const hasSession = session.resolved ? userId != null : hasStoredToken();
127
+ const state: SessionState = !onboardingDone ? "onboarding" : hasSession ? "ready" : "signedOut";
82
128
 
83
129
  const value = useMemo<SessionValue>(
84
130
  () => ({