@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.
- package/package.json +2 -2
- package/templates/_root/gitignore +1 -0
- package/templates/backend/mobile/apps/api/README.md +21 -1
- package/templates/backend/mobile/apps/api/app/(site)/layout.tsx +103 -0
- package/templates/backend/mobile/apps/api/app/(site)/not-found.tsx +23 -0
- package/templates/backend/mobile/apps/api/app/(site)/page.tsx +221 -0
- package/templates/backend/mobile/apps/api/app/(site)/privacy/page.tsx +131 -0
- package/templates/backend/mobile/apps/api/app/(site)/support/page.tsx +78 -0
- package/templates/backend/mobile/apps/api/app/(site)/terms/page.tsx +144 -0
- package/templates/backend/mobile/apps/api/app/error.tsx +27 -0
- package/templates/backend/mobile/apps/api/app/globals.css +87 -0
- package/templates/backend/mobile/apps/api/app/layout.tsx +27 -0
- package/templates/backend/mobile/apps/api/app/robots.ts +13 -0
- package/templates/backend/mobile/apps/api/app/sitemap.ts +16 -0
- package/templates/backend/mobile/apps/api/app.ts +11 -3
- package/templates/backend/mobile/apps/api/components/legal.tsx +64 -0
- package/templates/backend/mobile/apps/api/functions/createNote.ts +5 -1
- package/templates/backend/mobile/apps/api/functions/deleteMyData.ts +38 -0
- package/templates/backend/mobile/apps/api/lib/purchases.ts +8 -0
- package/templates/backend/mobile/apps/api/lib/site.ts +94 -0
- package/templates/backend/mobile/apps/api/package.json +8 -1
- package/templates/backend/mobile/apps/api/tsconfig.json +6 -2
- package/templates/expo/mobile/apps/expo/.env.example +13 -4
- package/templates/expo/mobile/apps/expo/README.md +35 -2
- package/templates/expo/mobile/apps/expo/STORE.md +31 -7
- package/templates/expo/mobile/apps/expo/app/(auth)/sign-in.tsx +17 -6
- package/templates/expo/mobile/apps/expo/app/(auth)/verify.tsx +3 -1
- package/templates/expo/mobile/apps/expo/app/(tabs)/settings.tsx +15 -7
- package/templates/expo/mobile/apps/expo/app/paywall.tsx +9 -13
- package/templates/expo/mobile/apps/expo/app.config.ts +29 -5
- package/templates/expo/mobile/apps/expo/babel.config.js +2 -2
- package/templates/expo/mobile/apps/expo/eas.json +6 -5
- package/templates/expo/mobile/apps/expo/package.json +9 -5
- package/templates/expo/mobile/apps/expo/src/entitlements.ts +4 -9
- package/templates/expo/mobile/apps/expo/src/links.ts +29 -0
- package/templates/expo/mobile/apps/expo/src/pylon.ts +7 -2
- 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
|
|
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
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
|
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={
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
8
|
-
* APP_SCHEME
|
|
9
|
-
* EAS_PROJECT_ID
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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.
|
|
49
|
-
"@types/react": "~19.2.
|
|
50
|
-
"
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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 [
|
|
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
|
|
41
|
-
if (
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
() => ({
|