@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
@@ -98,6 +98,12 @@ const TEMPLATE_REGISTRY = {
98
98
  platforms: [],
99
99
  unified: true,
100
100
  },
101
+ mobile: {
102
+ blurb:
103
+ "App Store-ready mobile app — Expo Router, guest-first onboarding, Sign in with Apple/Google/email code, RevenueCat paywall, offline sync, EAS profiles + a submission checklist. Backend in apps/api.",
104
+ platforms: ["expo"],
105
+ unified: false,
106
+ },
101
107
  consumer: {
102
108
  blurb:
103
109
  "Social feed — live posts + likes, public-read, owner-write. One SSR app.",
@@ -289,6 +295,7 @@ ${tmplLines.join("\n")}
289
295
  Examples:
290
296
  npm create @pylonsync/pylon my-app # default — the smallest SSR app that runs
291
297
  npm create @pylonsync/pylon my-app --template saas # building a product? start here: landing + orgs + billing
298
+ npm create @pylonsync/pylon my-app --template mobile # App Store-ready Expo app + backend: onboarding, sign-in, paywall
292
299
  npm create @pylonsync/pylon my-app --template todo # live, optimistic todo (SSR, one port)
293
300
  npm create @pylonsync/pylon my-app --template chat # realtime live chat room
294
301
  npm create @pylonsync/pylon my-app --template waitlist # coming-soon landing + live signup counter
@@ -379,7 +386,8 @@ if (!isUnified && !flags.platforms) {
379
386
  )
380
387
  ).trim()
381
388
  : "";
382
- flags.platforms = ans || "web";
389
+ flags.platforms =
390
+ ans || (TEMPLATE_REGISTRY[flags.template].platforms[0] ?? "web");
383
391
  }
384
392
  if (!flags.pm) {
385
393
  const detected = detectPackageManager();
@@ -417,7 +425,7 @@ rl.close();
417
425
  // everything else, parse + validate the platform list.
418
426
  const platforms = isUnified
419
427
  ? []
420
- : (flags.platforms ?? "web")
428
+ : (flags.platforms ?? TEMPLATE_REGISTRY[flags.template].platforms[0] ?? "web")
421
429
  .split(",")
422
430
  .map((p) => p.trim().toLowerCase())
423
431
  .filter(Boolean);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/create-pylon",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Scaffold a new Pylon app — realtime backend + web/mobile/expo frontends in one command. Run via `npm create @pylonsync/pylon@latest`.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,22 @@
1
+ # Copy to .env for local development. `pylon deploy` reads secrets from
2
+ # `pylon secrets set`, not from this file.
3
+
4
+ # --- Native sign-in ---------------------------------------------------------
5
+ # Sign in with Apple: the iOS bundle id (comma-separate several).
6
+ PYLON_APPLE_NATIVE_CLIENT_IDS=com.example.__APP_NAME_SNAKE__
7
+ # Google Sign-In: the iOS and Android OAuth client ids from Google Cloud.
8
+ PYLON_GOOGLE_NATIVE_CLIENT_IDS=
9
+
10
+ # --- Email codes -------------------------------------------------------------
11
+ # Magic codes are logged to the console in dev. For real email set a provider:
12
+ # PYLON_EMAIL_PROVIDER=resend
13
+ # RESEND_API_KEY=re_...
14
+ # PYLON_EMAIL_FROM="__APP_NAME__ <hello@example.com>"
15
+
16
+ # --- In-app purchases (RevenueCat) --------------------------------------------
17
+ # The Authorization header value you set on the RevenueCat webhook
18
+ # (Project → Integrations → Webhooks). Unset = webhook disabled.
19
+ REVENUECAT_WEBHOOK_AUTH=
20
+ # Secret API key (Project → API keys) so syncEntitlements can read a
21
+ # subscriber. A public SDK key also works for that read.
22
+ REVENUECAT_SECRET_KEY=
@@ -0,0 +1,31 @@
1
+ # __APP_NAME__ API
2
+
3
+ The Pylon backend for the __APP_NAME__ mobile app. One process serves the
4
+ data API, realtime sync, auth, and the RevenueCat webhook.
5
+
6
+ ```
7
+ app.ts User + Note + RcEntitlement (from @pylonsync/revenuecat)
8
+ functions/createNote server-enforced free-tier cap, then insert
9
+ functions/revenuecatWebhook, syncEntitlements, _pylonRcUpsertEntitlement
10
+ lib/purchases.ts the RevenueCat plugin instance + FREE_NOTE_LIMIT
11
+ ```
12
+
13
+ ## Run
14
+
15
+ ```bash
16
+ bun run dev # http://localhost:4321 — the Expo app points here in dev
17
+ ```
18
+
19
+ Magic codes print to this console in dev. Native sign-in needs the ids in
20
+ `.env.example`.
21
+
22
+ ## Deploy
23
+
24
+ ```bash
25
+ pylon deploy
26
+ pylon secrets set REVENUECAT_WEBHOOK_AUTH=... REVENUECAT_SECRET_KEY=... \
27
+ PYLON_APPLE_NATIVE_CLIENT_IDS=com.example.__APP_NAME_SNAKE__
28
+ ```
29
+
30
+ Then set `EXPO_PUBLIC_PYLON_BASE_URL` in `apps/expo/.env` to the deployed URL
31
+ and point the RevenueCat webhook at `<url>/api/fn/revenuecatWebhook`.
@@ -0,0 +1,89 @@
1
+ import {
2
+ auth,
3
+ buildManifest,
4
+ discoverFunctions,
5
+ entity,
6
+ field,
7
+ policy,
8
+ } from "@pylonsync/sdk";
9
+ // Native in-app purchases: RevenueCat events become RcEntitlement rows
10
+ // that sync to every device. See lib/purchases.ts.
11
+ import { purchases, FREE_NOTE_LIMIT } from "./lib/purchases";
12
+
13
+ // Accounts. Email code, Sign in with Apple, and Google Sign-In all land
14
+ // here. The app starts every user as a guest; signing in later merges the
15
+ // guest's rows into the account, so nothing made before sign-up is lost.
16
+ const User = entity(
17
+ "User",
18
+ {
19
+ email: field.string(),
20
+ displayName: field.string().optional(),
21
+ // The register / OAuth paths stamp these; the entity must declare them.
22
+ avatarColor: field.string().optional(),
23
+ emailVerified: field.datetime().optional(),
24
+ createdAt: field.datetime().defaultNow(),
25
+ },
26
+ { indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
27
+ );
28
+
29
+ // The app's data. One owner-scoped entity to build on: replace `Note` with
30
+ // whatever your app stores. `field.owner()` stamps the caller's user id on
31
+ // insert and the policy scopes every read and write to that owner.
32
+ const Note = entity(
33
+ "Note",
34
+ {
35
+ ownerId: field.id("User").owner(),
36
+ title: field.string(),
37
+ body: field.string().optional(),
38
+ createdAt: field.datetime().defaultNow(),
39
+ },
40
+ { indexes: [{ name: "by_owner", fields: ["ownerId"], unique: false }] },
41
+ );
42
+
43
+ const userPolicy = policy({
44
+ name: "user_self",
45
+ entity: "User",
46
+ allowRead: "auth.userId == data.id",
47
+ allowInsert: "false",
48
+ allowUpdate: "auth.userId == data.id",
49
+ allowDelete: "false",
50
+ });
51
+
52
+ // Reads, edits, and deletes go straight through sync. Inserts go through
53
+ // the `createNote` action so the free-tier cap is enforced on the server
54
+ // (a client cannot skip the paywall by writing the row itself).
55
+ const notePolicy = policy({
56
+ name: "note_owner",
57
+ entity: "Note",
58
+ allowRead: "auth.userId == data.ownerId",
59
+ allowInsert: "false",
60
+ allowUpdate: "auth.userId == data.ownerId",
61
+ allowDelete: "auth.userId == data.ownerId",
62
+ });
63
+
64
+ const fns = await discoverFunctions();
65
+
66
+ const manifest = buildManifest({
67
+ name: "__APP_NAME__",
68
+ version: "0.1.0",
69
+ entities: [User, Note, ...purchases.manifest.entities],
70
+ queries: fns.queries,
71
+ // The plugin's handlers are re-exported from functions/, so discovery
72
+ // already lists them; spreading `purchases.manifest.actions` too would
73
+ // register each twice.
74
+ actions: fns.actions,
75
+ policies: [userPolicy, notePolicy, ...purchases.manifest.policies],
76
+ // API only: the Expo app is the frontend.
77
+ routes: [],
78
+ // Email/password + magic codes are on by default. Native sign-in needs
79
+ // the app's ids on the server: PYLON_APPLE_NATIVE_CLIENT_IDS (the iOS
80
+ // bundle id) and PYLON_GOOGLE_NATIVE_CLIENT_IDS (the iOS + Android OAuth
81
+ // client ids). See .env.example.
82
+ auth: auth(),
83
+ });
84
+
85
+ // The CLI runs `bun run app.ts` and reads this as the manifest.
86
+ console.log(JSON.stringify(manifest, null, 2));
87
+
88
+ export { FREE_NOTE_LIMIT };
89
+ export default manifest;
@@ -0,0 +1 @@
1
+ export { _pylonRcUpsertEntitlement as default } from "../lib/purchases";
@@ -0,0 +1,44 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { hasEntitlement } from "@pylonsync/revenuecat";
3
+ import { FREE_NOTE_LIMIT, PRO_ENTITLEMENT } from "../lib/purchases";
4
+
5
+ /**
6
+ * Create a note for the caller. The free tier is capped here, on the
7
+ * server, so the paywall cannot be skipped by writing the row directly
8
+ * (the Note policy denies client inserts).
9
+ *
10
+ * Returns `LIMIT_REACHED` when a free account is at the cap; the app opens
11
+ * the paywall on that code.
12
+ */
13
+ export default mutation({
14
+ args: {
15
+ title: v.string(),
16
+ body: v.optional(v.string()),
17
+ },
18
+ async handler(ctx, args: { title: string; body?: string }) {
19
+ const userId = ctx.auth.userId;
20
+ if (!userId) throw ctx.error("UNAUTHENTICATED", "sign in first");
21
+ const title = args.title.trim();
22
+ if (!title) throw ctx.error("INVALID_ARGS", "title is required");
23
+
24
+ const entitlements = await ctx.db.query("RcEntitlement", { userId });
25
+ const pro = hasEntitlement(
26
+ entitlements as Array<{ entitlement: string; status: string; expiresAt?: string | null }>,
27
+ PRO_ENTITLEMENT,
28
+ );
29
+ if (!pro) {
30
+ const mine = await ctx.db.query("Note", { ownerId: userId });
31
+ if (mine.length >= FREE_NOTE_LIMIT) {
32
+ throw ctx.error(
33
+ "LIMIT_REACHED",
34
+ `Free accounts can keep ${FREE_NOTE_LIMIT} notes. Upgrade for unlimited notes.`,
35
+ );
36
+ }
37
+ }
38
+ return ctx.db.insert("Note", {
39
+ ownerId: userId,
40
+ title,
41
+ body: args.body?.trim() ?? "",
42
+ });
43
+ },
44
+ });
@@ -0,0 +1 @@
1
+ export { revenuecatWebhook as default } from "../lib/purchases";
@@ -0,0 +1 @@
1
+ export { syncEntitlements as default } from "../lib/purchases";
@@ -0,0 +1,22 @@
1
+ import { revenuecat } from "@pylonsync/revenuecat";
2
+
3
+ /** Entitlement identifier as configured in the RevenueCat dashboard. */
4
+ export const PRO_ENTITLEMENT = "pro";
5
+
6
+ /** Notes a free account can keep. The `createNote` action enforces it. */
7
+ export const FREE_NOTE_LIMIT = 10;
8
+
9
+ // One plugin instance for the whole app. Its manifest fragment is spread
10
+ // into app.ts; its handlers are re-exported from functions/ (one file per
11
+ // function, because the loader wants one default export per file).
12
+ //
13
+ // Env (apps/api/.env):
14
+ // REVENUECAT_WEBHOOK_AUTH the Authorization value you set on the dashboard webhook
15
+ // REVENUECAT_SECRET_KEY for syncEntitlements (a public SDK key also works)
16
+ // Point the dashboard webhook at https://<your-app>/api/fn/revenuecatWebhook.
17
+ export const purchases = revenuecat({
18
+ entitlements: [PRO_ENTITLEMENT],
19
+ });
20
+
21
+ export const { revenuecatWebhook, syncEntitlements } = purchases.handlers;
22
+ export const { _pylonRcUpsertEntitlement } = purchases.internals;
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@__APP_NAME_KEBAB__/api",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "pylon dev",
8
+ "build": "pylon build",
9
+ "deploy": "pylon deploy",
10
+ "check": "tsc --noEmit",
11
+ "test": "pylon test"
12
+ },
13
+ "dependencies": {
14
+ "@pylonsync/sdk": "^__PYLON_VERSION__",
15
+ "@pylonsync/functions": "^__PYLON_VERSION__",
16
+ "@pylonsync/revenuecat": "^__PYLON_VERSION__"
17
+ },
18
+ "devDependencies": {
19
+ "@pylonsync/cli": "^__PYLON_VERSION__",
20
+ "@types/node": "^22.0.0",
21
+ "typescript": "^5.6.0"
22
+ }
23
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "types": ["node"]
11
+ },
12
+ "include": ["app.ts", "functions", "lib"]
13
+ }
@@ -0,0 +1,19 @@
1
+ # Copy to .env. Only EXPO_PUBLIC_* vars reach the app bundle.
2
+
3
+ # The Pylon backend. Local: the api app on your machine. Android emulator
4
+ # reaches the host at 10.0.2.2 (handled in src/pylon.ts when unset).
5
+ EXPO_PUBLIC_PYLON_BASE_URL=http://localhost:4321
6
+
7
+ # RevenueCat public SDK keys (Project → API keys). One per platform.
8
+ EXPO_PUBLIC_REVENUECAT_IOS_KEY=
9
+ EXPO_PUBLIC_REVENUECAT_ANDROID_KEY=
10
+
11
+ # Google Sign-In OAuth client ids (Google Cloud → Credentials). The web
12
+ # client id is what the SDK calls webClientId; the iOS id is the iOS client.
13
+ EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID=
14
+ EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID=
15
+
16
+ # Shown in Settings and required by both stores.
17
+ EXPO_PUBLIC_PRIVACY_URL=https://example.com/privacy
18
+ EXPO_PUBLIC_TERMS_URL=https://example.com/terms
19
+ EXPO_PUBLIC_SUPPORT_EMAIL=support@example.com
@@ -0,0 +1,34 @@
1
+ # __APP_NAME__ (Expo)
2
+
3
+ The mobile app. Expo Router, guest-first onboarding, Sign in with Apple /
4
+ Google / email code, a RevenueCat paywall, and an offline-capable synced
5
+ list, wired to the Pylon backend in `apps/api`.
6
+
7
+ ```
8
+ app/_layout.tsx session-driven route guard
9
+ app/(onboarding)/welcome three slides → guest session → paywall (once) → app
10
+ app/(auth)/sign-in, verify Apple, Google, email code
11
+ app/paywall RevenueCat offering; annual preselected; restore
12
+ app/(tabs)/index the Notes list (free cap enforced server-side)
13
+ app/(tabs)/settings account, subscription, legal, delete account
14
+ src/session.tsx boot + state machine
15
+ src/purchases.ts RevenueCat wrapper, safe in Expo Go
16
+ src/entitlements.ts usePro() from the synced RcEntitlement rows
17
+ src/analytics.ts funnel events; wire to your SDK in one place
18
+ STORE.md the submission checklist
19
+ ```
20
+
21
+ ## Run
22
+
23
+ ```bash
24
+ cp .env.example .env
25
+ bun run dev # Expo Go: everything except native sign-in and purchases
26
+ eas build --profile development --platform ios && bun run dev # dev build: everything
27
+ ```
28
+
29
+ The backend must be running (`cd ../api && bun run dev`) or deployed
30
+ (`EXPO_PUBLIC_PYLON_BASE_URL` in `.env`).
31
+
32
+ ## Ship
33
+
34
+ See `STORE.md`.
@@ -0,0 +1,82 @@
1
+ # From scaffold to the App Store
2
+
3
+ The order below is the shortest path that App Review and Google Play accept
4
+ on the first try. Each step is one command or one dashboard page.
5
+
6
+ ## 1. Backend live
7
+
8
+ ```bash
9
+ cd apps/api && pylon deploy
10
+ pylon secrets set PYLON_APPLE_NATIVE_CLIENT_IDS=<bundle id> REVENUECAT_WEBHOOK_AUTH=<random> REVENUECAT_SECRET_KEY=<key>
11
+ ```
12
+
13
+ Put the deployed URL in `apps/expo/eas.json` (`EXPO_PUBLIC_PYLON_BASE_URL`
14
+ under `preview` and `production`).
15
+
16
+ ## 2. Identifiers
17
+
18
+ - 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.
20
+ - `eas init` prints the project id; set `EAS_PROJECT_ID` the same way.
21
+ - Apple: an App ID with the Sign in with Apple capability. EAS creates it on
22
+ the first `eas build` when you let it manage credentials.
23
+ - Google: OAuth client ids (iOS + Android + Web) in Google Cloud. Put the
24
+ web and iOS ids in `apps/expo/.env` and both ids in
25
+ `PYLON_GOOGLE_NATIVE_CLIENT_IDS` on the backend.
26
+
27
+ ## 3. Products
28
+
29
+ 1. App Store Connect → the app → Subscriptions: one group, monthly + annual
30
+ (annual with a 7-day introductory free trial converts best).
31
+ 2. Google Play Console → Monetize → Subscriptions: the same two products.
32
+ 3. RevenueCat → Project: add both apps, import the products, make an
33
+ entitlement `pro`, and an offering "default" with a monthly and an annual
34
+ package. Copy the iOS and Android public SDK keys to `apps/expo/.env`.
35
+ 4. RevenueCat → Integrations → Webhooks: URL `<backend>/api/fn/revenuecatWebhook`,
36
+ Authorization header = the `REVENUECAT_WEBHOOK_AUTH` value.
37
+
38
+ ## 4. Build
39
+
40
+ ```bash
41
+ eas build --profile development --platform ios # dev client for the simulator
42
+ eas build --profile preview --platform all # TestFlight / internal testing
43
+ eas build --profile production --platform all # store build
44
+ ```
45
+
46
+ Test the purchase on TestFlight with a sandbox Apple ID before submitting.
47
+
48
+ ## 5. Store listing
49
+
50
+ Both stores require, and reviewers check:
51
+
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.
54
+ - Account deletion inside the app (Settings → Delete account). Present.
55
+ - Sign in with Apple when any other third-party sign-in is offered. Present.
56
+ - Subscription terms next to the purchase button (price, period, renewal). Present.
57
+ - Restore purchases. Present in the paywall and Settings.
58
+ - App Privacy answers: this template stores email (account), user content
59
+ (notes), and purchase history (via RevenueCat). No tracking.
60
+ - Screenshots: 6.7" and 6.1" iPhone; 7" and 10" tablets are optional when
61
+ `supportsTablet` is false.
62
+ - A demo account for the reviewer: create one with an email code and put
63
+ the address plus "use the email code sent to this address" in Review
64
+ Notes, or turn on `PYLON_DEV_MODE`-style test codes on a staging backend.
65
+
66
+ ## 6. Submit
67
+
68
+ ```bash
69
+ eas submit --platform ios --latest
70
+ eas submit --platform android --latest
71
+ ```
72
+
73
+ Fill `submit.production.ios.ascAppId` in `eas.json` with the App Store
74
+ Connect app id first.
75
+
76
+ ## After launch
77
+
78
+ - The `track()` calls in `src/analytics.ts` already name the funnel:
79
+ onboarding → paywall_shown → purchase_completed. Wire them to your
80
+ analytics SDK to see conversion per step.
81
+ - Prices, trials, and which packages appear are RevenueCat offering
82
+ settings; change them without shipping an update.
@@ -0,0 +1,6 @@
1
+ import React from "react";
2
+ import { Stack } from "expo-router";
3
+
4
+ export default function AuthLayout() {
5
+ return <Stack screenOptions={{ headerShown: false }} />;
6
+ }
@@ -0,0 +1,194 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { Alert, Platform, Text, View } from "react-native";
3
+ import { useLocalSearchParams, useRouter } from "expo-router";
4
+ import { nativeSignIn, sendEmailCode } from "@pylonsync/react-native";
5
+ import { track } from "@/analytics";
6
+ import { useAppSession } from "@/session";
7
+ import { space, useTheme } from "@/theme";
8
+ import { Body, Button, Caption, Field, Screen, Spacer, Title } from "@/ui";
9
+
10
+ type AppleAuth = typeof import("expo-apple-authentication");
11
+ type GoogleSignin = typeof import("@react-native-google-signin/google-signin");
12
+
13
+ function loadApple(): AppleAuth | null {
14
+ if (Platform.OS !== "ios") return null;
15
+ try {
16
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
17
+ return require("expo-apple-authentication") as AppleAuth;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function loadGoogle(): GoogleSignin | null {
24
+ if (!process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID) return null;
25
+ try {
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
27
+ return require("@react-native-google-signin/google-signin") as GoogleSignin;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Sign in or create an account. Every method lands on the same server
35
+ * session; a guest's data merges into the account on first sign-in.
36
+ *
37
+ * Apple iOS only. Required by App Review when any third-party sign-in
38
+ * is offered. Reveals the name only on the first sign-in, which
39
+ * is forwarded to the server.
40
+ * Google when EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID is set.
41
+ * Email a 6-digit code; no password to remember.
42
+ */
43
+ export default function SignIn() {
44
+ const router = useRouter();
45
+ const t = useTheme();
46
+ const params = useLocalSearchParams<{ next?: string }>();
47
+ const { continueAsGuest, state } = useAppSession();
48
+ const [email, setEmail] = useState("");
49
+ const [busy, setBusy] = useState<string | null>(null);
50
+ const [appleAvailable, setAppleAvailable] = useState(false);
51
+ const apple = loadApple();
52
+ const google = loadGoogle();
53
+
54
+ useEffect(() => {
55
+ void apple?.isAvailableAsync().then(setAppleAvailable);
56
+ google?.GoogleSignin.configure({
57
+ webClientId: process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID,
58
+ iosClientId: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID,
59
+ });
60
+ }, [apple, google]);
61
+
62
+ function done() {
63
+ track("sign_in_completed");
64
+ if (params.next) router.replace(params.next as never);
65
+ else router.replace("/(tabs)");
66
+ }
67
+
68
+ async function withApple() {
69
+ if (!apple) return;
70
+ setBusy("apple");
71
+ track("sign_in_started", { method: "apple" });
72
+ try {
73
+ const credential = await apple.signInAsync({
74
+ requestedScopes: [
75
+ apple.AppleAuthenticationScope.FULL_NAME,
76
+ apple.AppleAuthenticationScope.EMAIL,
77
+ ],
78
+ });
79
+ if (!credential.identityToken) throw new Error("Apple returned no identity token");
80
+ const name = [credential.fullName?.givenName, credential.fullName?.familyName]
81
+ .filter(Boolean)
82
+ .join(" ");
83
+ await nativeSignIn("apple", credential.identityToken, name || undefined);
84
+ done();
85
+ } catch (e) {
86
+ if ((e as { code?: string })?.code !== "ERR_REQUEST_CANCELED") {
87
+ Alert.alert("Sign in failed", messageOf(e));
88
+ }
89
+ } finally {
90
+ setBusy(null);
91
+ }
92
+ }
93
+
94
+ async function withGoogle() {
95
+ if (!google) return;
96
+ setBusy("google");
97
+ track("sign_in_started", { method: "google" });
98
+ try {
99
+ await google.GoogleSignin.hasPlayServices();
100
+ const result = await google.GoogleSignin.signIn();
101
+ const idToken = result.type === "success" ? result.data.idToken : null;
102
+ if (!idToken) return;
103
+ await nativeSignIn("google", idToken, result.type === "success" ? result.data.user.name : undefined);
104
+ done();
105
+ } catch (e) {
106
+ Alert.alert("Sign in failed", messageOf(e));
107
+ } finally {
108
+ setBusy(null);
109
+ }
110
+ }
111
+
112
+ async function withEmail() {
113
+ const address = email.trim().toLowerCase();
114
+ if (!address.includes("@")) {
115
+ Alert.alert("Enter your email address");
116
+ return;
117
+ }
118
+ setBusy("email");
119
+ track("sign_in_started", { method: "email" });
120
+ try {
121
+ await sendEmailCode(address);
122
+ router.push({ pathname: "/(auth)/verify", params: { email: address, next: params.next ?? "" } });
123
+ } catch (e) {
124
+ Alert.alert("Couldn't send the code", messageOf(e));
125
+ } finally {
126
+ setBusy(null);
127
+ }
128
+ }
129
+
130
+ return (
131
+ <Screen>
132
+ <Spacer h={space.xxl} />
133
+ <Title>Sign in</Title>
134
+ <Body muted style={{ marginTop: space.sm }}>
135
+ Keep your notes on every device. Anything you made already comes with you.
136
+ </Body>
137
+ <Spacer h={space.xxl} />
138
+ <View style={{ gap: space.md }}>
139
+ {appleAvailable && apple ? (
140
+ <apple.AppleAuthenticationButton
141
+ buttonType={apple.AppleAuthenticationButtonType.CONTINUE}
142
+ buttonStyle={
143
+ t.bg === "#ffffff"
144
+ ? apple.AppleAuthenticationButtonStyle.BLACK
145
+ : apple.AppleAuthenticationButtonStyle.WHITE
146
+ }
147
+ cornerRadius={12}
148
+ style={{ height: 52 }}
149
+ onPress={() => void withApple()}
150
+ />
151
+ ) : null}
152
+ {google ? (
153
+ <Button title="Continue with Google" variant="secondary" loading={busy === "google"} onPress={() => void withGoogle()} />
154
+ ) : null}
155
+ <View style={{ flexDirection: "row", alignItems: "center", gap: space.md, marginVertical: space.sm }}>
156
+ <View style={{ flex: 1, height: 1, backgroundColor: t.border }} />
157
+ <Caption>or</Caption>
158
+ <View style={{ flex: 1, height: 1, backgroundColor: t.border }} />
159
+ </View>
160
+ <Field
161
+ placeholder="you@example.com"
162
+ autoCapitalize="none"
163
+ autoCorrect={false}
164
+ keyboardType="email-address"
165
+ textContentType="emailAddress"
166
+ autoComplete="email"
167
+ value={email}
168
+ onChangeText={setEmail}
169
+ onSubmitEditing={() => void withEmail()}
170
+ returnKeyType="send"
171
+ />
172
+ <Button title="Email me a code" loading={busy === "email"} onPress={() => void withEmail()} />
173
+ </View>
174
+ <Spacer />
175
+ {state === "signedOut" ? (
176
+ <Button
177
+ title="Continue without an account"
178
+ variant="ghost"
179
+ onPress={() => void continueAsGuest().then(() => router.replace("/(tabs)"))}
180
+ />
181
+ ) : (
182
+ <Button title="Not now" variant="ghost" onPress={() => router.back()} />
183
+ )}
184
+ <Spacer />
185
+ <Text style={{ color: t.muted, fontSize: 12, textAlign: "center", lineHeight: 18 }}>
186
+ By continuing you agree to the Terms and Privacy Policy.
187
+ </Text>
188
+ </Screen>
189
+ );
190
+ }
191
+
192
+ function messageOf(e: unknown): string {
193
+ return e instanceof Error ? e.message : String(e);
194
+ }