@pylonsync/create-pylon 0.8.1 → 0.8.3
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/bin/create-pylon.js +10 -2
- package/package.json +1 -1
- package/templates/backend/mobile/apps/api/.env.example +22 -0
- package/templates/backend/mobile/apps/api/README.md +31 -0
- package/templates/backend/mobile/apps/api/app.ts +89 -0
- package/templates/backend/mobile/apps/api/functions/_pylonRcUpsertEntitlement.ts +1 -0
- package/templates/backend/mobile/apps/api/functions/createNote.ts +44 -0
- package/templates/backend/mobile/apps/api/functions/revenuecatWebhook.ts +1 -0
- package/templates/backend/mobile/apps/api/functions/syncEntitlements.ts +1 -0
- package/templates/backend/mobile/apps/api/lib/purchases.ts +22 -0
- package/templates/backend/mobile/apps/api/package.json +23 -0
- package/templates/backend/mobile/apps/api/tsconfig.json +13 -0
- package/templates/expo/mobile/apps/expo/.env.example +19 -0
- package/templates/expo/mobile/apps/expo/README.md +34 -0
- package/templates/expo/mobile/apps/expo/STORE.md +82 -0
- package/templates/expo/mobile/apps/expo/app/(auth)/_layout.tsx +6 -0
- package/templates/expo/mobile/apps/expo/app/(auth)/sign-in.tsx +194 -0
- package/templates/expo/mobile/apps/expo/app/(auth)/verify.tsx +78 -0
- package/templates/expo/mobile/apps/expo/app/(onboarding)/_layout.tsx +6 -0
- package/templates/expo/mobile/apps/expo/app/(onboarding)/welcome.tsx +125 -0
- package/templates/expo/mobile/apps/expo/app/(tabs)/_layout.tsx +27 -0
- package/templates/expo/mobile/apps/expo/app/(tabs)/index.tsx +128 -0
- package/templates/expo/mobile/apps/expo/app/(tabs)/settings.tsx +122 -0
- package/templates/expo/mobile/apps/expo/app/_layout.tsx +61 -0
- package/templates/expo/mobile/apps/expo/app/index.tsx +15 -0
- package/templates/expo/mobile/apps/expo/app/paywall.tsx +177 -0
- package/templates/expo/mobile/apps/expo/app.config.ts +70 -0
- package/templates/expo/mobile/apps/expo/assets/adaptive-icon.png +0 -0
- package/templates/expo/mobile/apps/expo/assets/icon.png +0 -0
- package/templates/expo/mobile/apps/expo/assets/splash-icon.png +0 -0
- package/templates/expo/mobile/apps/expo/babel.config.js +8 -0
- package/templates/expo/mobile/apps/expo/eas.json +45 -0
- package/templates/expo/mobile/apps/expo/package.json +52 -0
- package/templates/expo/mobile/apps/expo/src/analytics.ts +25 -0
- package/templates/expo/mobile/apps/expo/src/entitlements.ts +21 -0
- package/templates/expo/mobile/apps/expo/src/flags.ts +31 -0
- package/templates/expo/mobile/apps/expo/src/purchases.ts +164 -0
- package/templates/expo/mobile/apps/expo/src/pylon.ts +23 -0
- package/templates/expo/mobile/apps/expo/src/session.tsx +101 -0
- package/templates/expo/mobile/apps/expo/src/theme.ts +36 -0
- package/templates/expo/mobile/apps/expo/src/ui.tsx +187 -0
- package/templates/expo/mobile/apps/expo/tsconfig.json +10 -0
- package/templates/saas/.env.example +2 -0
- package/templates/saas/README.md +12 -8
- package/templates/saas/app/(auth)/auth-form.tsx +136 -95
- package/templates/saas/app/(auth)/forgot-password/page.tsx +20 -0
- package/templates/saas/app/(auth)/recovery-forms.tsx +135 -0
- package/templates/saas/app/(auth)/reset-password/page.tsx +24 -0
- package/templates/saas/app/(marketing)/layout.tsx +1 -1
- package/templates/saas/app/(marketing)/page.tsx +3 -52
- package/templates/saas/app/(marketing)/pricing/page.tsx +43 -0
- package/templates/saas/app/dashboard/dashboard-client.tsx +368 -18
- package/templates/saas/app/dashboard/page.tsx +30 -2
- package/templates/saas/app/dashboard/provision-workspace.tsx +10 -8
- package/templates/saas/app/dashboard/settings/page.tsx +3 -0
- package/templates/saas/app/onboarding/onboarding-client.tsx +250 -0
- package/templates/saas/app/onboarding/page.tsx +34 -0
- package/templates/saas/app/sitemap.ts +1 -0
- package/templates/saas/app.ts +18 -10
- package/templates/saas/components/pricing-table.tsx +97 -0
- package/templates/saas/components/setup-checklist.tsx +83 -0
- package/templates/saas/functions/completeOnboarding.ts +15 -0
- package/templates/saas/functions/createProject.ts +56 -0
- package/templates/saas/functions/dismissSetup.ts +14 -0
- package/templates/saas/functions/updateProfile.ts +18 -0
- package/templates/saas/lib/billing.ts +10 -3
- package/templates/saas/lib/plans.ts +94 -0
- package/templates/saas/lib/site.config.ts +13 -45
- package/templates/saas/tests/plans.test.ts +35 -0
- package/templates/saas/app/not-found.tsx +0 -44
package/bin/create-pylon.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
3
|
+
"version": "0.8.3",
|
|
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,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
|
+
}
|