@pylonsync/create-pylon 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/bin/create-pylon.js +10 -2
  2. package/package.json +1 -1
  3. package/templates/backend/mobile/apps/api/.env.example +22 -0
  4. package/templates/backend/mobile/apps/api/README.md +31 -0
  5. package/templates/backend/mobile/apps/api/app.ts +89 -0
  6. package/templates/backend/mobile/apps/api/functions/_pylonRcUpsertEntitlement.ts +1 -0
  7. package/templates/backend/mobile/apps/api/functions/createNote.ts +44 -0
  8. package/templates/backend/mobile/apps/api/functions/revenuecatWebhook.ts +1 -0
  9. package/templates/backend/mobile/apps/api/functions/syncEntitlements.ts +1 -0
  10. package/templates/backend/mobile/apps/api/lib/purchases.ts +22 -0
  11. package/templates/backend/mobile/apps/api/package.json +23 -0
  12. package/templates/backend/mobile/apps/api/tsconfig.json +13 -0
  13. package/templates/expo/mobile/apps/expo/.env.example +19 -0
  14. package/templates/expo/mobile/apps/expo/README.md +34 -0
  15. package/templates/expo/mobile/apps/expo/STORE.md +82 -0
  16. package/templates/expo/mobile/apps/expo/app/(auth)/_layout.tsx +6 -0
  17. package/templates/expo/mobile/apps/expo/app/(auth)/sign-in.tsx +194 -0
  18. package/templates/expo/mobile/apps/expo/app/(auth)/verify.tsx +78 -0
  19. package/templates/expo/mobile/apps/expo/app/(onboarding)/_layout.tsx +6 -0
  20. package/templates/expo/mobile/apps/expo/app/(onboarding)/welcome.tsx +125 -0
  21. package/templates/expo/mobile/apps/expo/app/(tabs)/_layout.tsx +27 -0
  22. package/templates/expo/mobile/apps/expo/app/(tabs)/index.tsx +128 -0
  23. package/templates/expo/mobile/apps/expo/app/(tabs)/settings.tsx +122 -0
  24. package/templates/expo/mobile/apps/expo/app/_layout.tsx +61 -0
  25. package/templates/expo/mobile/apps/expo/app/index.tsx +15 -0
  26. package/templates/expo/mobile/apps/expo/app/paywall.tsx +177 -0
  27. package/templates/expo/mobile/apps/expo/app.config.ts +70 -0
  28. package/templates/expo/mobile/apps/expo/assets/adaptive-icon.png +0 -0
  29. package/templates/expo/mobile/apps/expo/assets/icon.png +0 -0
  30. package/templates/expo/mobile/apps/expo/assets/splash-icon.png +0 -0
  31. package/templates/expo/mobile/apps/expo/babel.config.js +8 -0
  32. package/templates/expo/mobile/apps/expo/eas.json +45 -0
  33. package/templates/expo/mobile/apps/expo/package.json +52 -0
  34. package/templates/expo/mobile/apps/expo/src/analytics.ts +25 -0
  35. package/templates/expo/mobile/apps/expo/src/entitlements.ts +21 -0
  36. package/templates/expo/mobile/apps/expo/src/flags.ts +31 -0
  37. package/templates/expo/mobile/apps/expo/src/purchases.ts +164 -0
  38. package/templates/expo/mobile/apps/expo/src/pylon.ts +23 -0
  39. package/templates/expo/mobile/apps/expo/src/session.tsx +101 -0
  40. package/templates/expo/mobile/apps/expo/src/theme.ts +36 -0
  41. package/templates/expo/mobile/apps/expo/src/ui.tsx +187 -0
  42. package/templates/expo/mobile/apps/expo/tsconfig.json +10 -0
  43. package/templates/saas/.env.example +2 -0
  44. package/templates/saas/README.md +12 -8
  45. package/templates/saas/app/(auth)/auth-form.tsx +136 -95
  46. package/templates/saas/app/(auth)/forgot-password/page.tsx +20 -0
  47. package/templates/saas/app/(auth)/recovery-forms.tsx +135 -0
  48. package/templates/saas/app/(auth)/reset-password/page.tsx +24 -0
  49. package/templates/saas/app/(marketing)/layout.tsx +1 -1
  50. package/templates/saas/app/(marketing)/page.tsx +3 -52
  51. package/templates/saas/app/(marketing)/pricing/page.tsx +43 -0
  52. package/templates/saas/app/dashboard/dashboard-client.tsx +368 -18
  53. package/templates/saas/app/dashboard/page.tsx +30 -2
  54. package/templates/saas/app/dashboard/provision-workspace.tsx +10 -8
  55. package/templates/saas/app/dashboard/settings/page.tsx +3 -0
  56. package/templates/saas/app/onboarding/onboarding-client.tsx +250 -0
  57. package/templates/saas/app/onboarding/page.tsx +34 -0
  58. package/templates/saas/app/sitemap.ts +1 -0
  59. package/templates/saas/app.ts +18 -10
  60. package/templates/saas/components/pricing-table.tsx +97 -0
  61. package/templates/saas/components/setup-checklist.tsx +83 -0
  62. package/templates/saas/functions/completeOnboarding.ts +15 -0
  63. package/templates/saas/functions/createProject.ts +56 -0
  64. package/templates/saas/functions/dismissSetup.ts +14 -0
  65. package/templates/saas/functions/updateProfile.ts +18 -0
  66. package/templates/saas/lib/billing.ts +10 -3
  67. package/templates/saas/lib/plans.ts +94 -0
  68. package/templates/saas/lib/site.config.ts +13 -45
  69. package/templates/saas/tests/plans.test.ts +35 -0
  70. package/templates/saas/app/not-found.tsx +0 -44
@@ -0,0 +1,177 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { Alert, Linking, Pressable, Text, View } from "react-native";
3
+ import { useLocalSearchParams, useRouter } from "expo-router";
4
+ import { track } from "@/analytics";
5
+ import { usePro } from "@/entitlements";
6
+ import { available, offers, purchase, restore, type Offer } from "@/purchases";
7
+ import { radius, space, useTheme } from "@/theme";
8
+ import { Body, Button, Caption, Screen, Spacer, Title } from "@/ui";
9
+
10
+ const BENEFITS = [
11
+ "Unlimited notes",
12
+ "Sync across all your devices",
13
+ "Everything we ship next",
14
+ ];
15
+
16
+ /**
17
+ * The paywall. Shown once after onboarding, again when the free tier's
18
+ * cap is hit (`reason=limit`), and from Settings. Annual is preselected
19
+ * with its saving called out; a free trial is shown when the store
20
+ * product has one.
21
+ *
22
+ * Products come from RevenueCat's current offering, so prices, trials, and
23
+ * the packages on offer change from the dashboard without an app update.
24
+ */
25
+ export default function Paywall() {
26
+ const router = useRouter();
27
+ const t = useTheme();
28
+ const { reason } = useLocalSearchParams<{ reason?: string }>();
29
+ const { pro } = usePro();
30
+ const [list, setList] = useState<Offer[] | null>(null);
31
+ const [selected, setSelected] = useState<string | null>(null);
32
+ const [busy, setBusy] = useState<"buy" | "restore" | null>(null);
33
+
34
+ useEffect(() => {
35
+ track("paywall_shown", { reason: reason ?? "onboarding" });
36
+ void offers().then((o) => {
37
+ setList(o);
38
+ const annual = o.find((x) => x.period === "annual");
39
+ setSelected((annual ?? o[0])?.id ?? null);
40
+ });
41
+ }, [reason]);
42
+
43
+ // Purchased (or already Pro): close.
44
+ useEffect(() => {
45
+ if (pro) router.back();
46
+ }, [pro, router]);
47
+
48
+ const privacy = process.env.EXPO_PUBLIC_PRIVACY_URL;
49
+ const terms = process.env.EXPO_PUBLIC_TERMS_URL;
50
+
51
+ async function buy() {
52
+ const offer = list?.find((o) => o.id === selected);
53
+ if (!offer) return;
54
+ setBusy("buy");
55
+ try {
56
+ const ok = await purchase(offer);
57
+ if (!ok) return;
58
+ // The synced entitlement row closes the modal via the effect above.
59
+ } catch (e) {
60
+ Alert.alert("Purchase failed", e instanceof Error ? e.message : String(e));
61
+ } finally {
62
+ setBusy(null);
63
+ }
64
+ }
65
+
66
+ async function restorePurchases() {
67
+ setBusy("restore");
68
+ try {
69
+ const ok = await restore();
70
+ if (!ok) Alert.alert("Nothing to restore", "No previous purchase was found for this store account.");
71
+ } finally {
72
+ setBusy(null);
73
+ }
74
+ }
75
+
76
+ function dismiss() {
77
+ track("paywall_dismissed", { reason: reason ?? "onboarding" });
78
+ router.back();
79
+ }
80
+
81
+ const chosen = list?.find((o) => o.id === selected);
82
+ const cta = chosen?.trial ? `Start ${chosen.trial} free` : "Continue";
83
+
84
+ return (
85
+ <Screen>
86
+ <View style={{ alignItems: "flex-end", minHeight: 44 }}>
87
+ <Pressable onPress={dismiss} hitSlop={12} accessibilityRole="button" accessibilityLabel="Close">
88
+ <Text style={{ color: t.muted, fontSize: 22 }}>×</Text>
89
+ </Pressable>
90
+ </View>
91
+ <Title>{reason === "limit" ? "You've hit the free limit" : "Go Pro"}</Title>
92
+ <Body muted style={{ marginTop: space.sm }}>
93
+ {reason === "limit"
94
+ ? "Upgrade to keep adding notes without limits."
95
+ : "Everything in the free version, without the limits."}
96
+ </Body>
97
+ <Spacer />
98
+ <View style={{ gap: space.sm }}>
99
+ {BENEFITS.map((b) => (
100
+ <View key={b} style={{ flexDirection: "row", gap: space.md, alignItems: "center" }}>
101
+ <Text style={{ color: t.success, fontSize: 16 }}>✓</Text>
102
+ <Body>{b}</Body>
103
+ </View>
104
+ ))}
105
+ </View>
106
+ <Spacer h={space.xxl} />
107
+
108
+ {!available() ? (
109
+ <View style={{ padding: space.lg, borderRadius: radius.lg, backgroundColor: t.surface, gap: space.sm }}>
110
+ <Body>Purchases need a development build.</Body>
111
+ <Caption>
112
+ Set EXPO_PUBLIC_REVENUECAT_IOS_KEY / _ANDROID_KEY and run `eas build --profile development`.
113
+ Expo Go cannot load the native purchase module.
114
+ </Caption>
115
+ </View>
116
+ ) : list === null ? (
117
+ <Caption>Loading plans…</Caption>
118
+ ) : list.length === 0 ? (
119
+ <Caption>No products are configured for this offering yet. Add packages to the current offering in RevenueCat.</Caption>
120
+ ) : (
121
+ <View style={{ gap: space.md }}>
122
+ {list.map((o) => {
123
+ const active = o.id === selected;
124
+ return (
125
+ <Pressable
126
+ key={o.id}
127
+ accessibilityRole="radio"
128
+ accessibilityState={{ selected: active }}
129
+ onPress={() => setSelected(o.id)}
130
+ style={{
131
+ borderWidth: 2,
132
+ borderColor: active ? t.text : t.border,
133
+ borderRadius: radius.lg,
134
+ padding: space.lg,
135
+ backgroundColor: active ? t.surface : "transparent",
136
+ }}
137
+ >
138
+ <View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
139
+ <View>
140
+ <Text style={{ color: t.text, fontSize: 16, fontWeight: "600", textTransform: "capitalize" }}>
141
+ {o.period}
142
+ </Text>
143
+ {o.trial ? <Caption>{o.trial} free, then {o.price}</Caption> : <Caption>{o.price}</Caption>}
144
+ </View>
145
+ {o.period === "annual" ? (
146
+ <View style={{ backgroundColor: t.text, borderRadius: radius.pill, paddingHorizontal: 10, paddingVertical: 4 }}>
147
+ <Text style={{ color: t.bg, fontSize: 12, fontWeight: "700" }}>BEST VALUE</Text>
148
+ </View>
149
+ ) : null}
150
+ </View>
151
+ </Pressable>
152
+ );
153
+ })}
154
+ </View>
155
+ )}
156
+
157
+ <View style={{ flex: 1 }} />
158
+ <Button title={cta} loading={busy === "buy"} disabled={!chosen} onPress={() => void buy()} />
159
+ <Spacer h={space.sm} />
160
+ <Button title="Restore purchases" variant="ghost" loading={busy === "restore"} onPress={() => void restorePurchases()} />
161
+ <Text style={{ color: t.muted, fontSize: 11, textAlign: "center", lineHeight: 16, marginTop: space.sm }}>
162
+ Renews automatically until cancelled. Manage in your store account settings.{" "}
163
+ {terms ? (
164
+ <Text style={{ textDecorationLine: "underline" }} onPress={() => void Linking.openURL(terms)}>
165
+ Terms
166
+ </Text>
167
+ ) : null}
168
+ {terms && privacy ? " · " : ""}
169
+ {privacy ? (
170
+ <Text style={{ textDecorationLine: "underline" }} onPress={() => void Linking.openURL(privacy)}>
171
+ Privacy
172
+ </Text>
173
+ ) : null}
174
+ </Text>
175
+ </Screen>
176
+ );
177
+ }
@@ -0,0 +1,70 @@
1
+ import type { ConfigContext, ExpoConfig } from "expo/config";
2
+
3
+ /**
4
+ * App config. The values that change per environment come from env vars
5
+ * so one file serves development builds, TestFlight, and the store:
6
+ *
7
+ * APP_BUNDLE_ID reverse-DNS id (iOS bundle id + Android package)
8
+ * APP_SCHEME deep-link scheme, e.g. "__APP_NAME_KEBAB__"
9
+ * EAS_PROJECT_ID from `eas init` (printed once, then stable)
10
+ * APP_VARIANT "development" | "preview" | "production" (set by eas.json)
11
+ *
12
+ * A development build gets a ".dev" suffix so it installs alongside the
13
+ * store app on the same phone.
14
+ */
15
+ const variant = process.env.APP_VARIANT ?? "development";
16
+ const baseBundleId = process.env.APP_BUNDLE_ID ?? "com.example.__APP_NAME_SNAKE__";
17
+ const bundleId = variant === "development" ? `${baseBundleId}.dev` : baseBundleId;
18
+ const name = variant === "production" ? "__APP_NAME__" : `__APP_NAME__ (${variant})`;
19
+
20
+ export default ({ config }: ConfigContext): ExpoConfig => ({
21
+ ...config,
22
+ name,
23
+ slug: "__APP_NAME_KEBAB__",
24
+ version: "1.0.0",
25
+ scheme: process.env.APP_SCHEME ?? "__APP_NAME_KEBAB__",
26
+ orientation: "portrait",
27
+ icon: "./assets/icon.png",
28
+ userInterfaceStyle: "automatic",
29
+ ios: {
30
+ bundleIdentifier: bundleId,
31
+ supportsTablet: false,
32
+ // Sign in with Apple. Also enable the capability on the App ID in
33
+ // the Apple Developer portal (EAS does this for you on first build).
34
+ usesAppleSignIn: true,
35
+ infoPlist: {
36
+ // Local dev talks to http://localhost:4321.
37
+ NSAppTransportSecurity: { NSAllowsLocalNetworking: true },
38
+ // App Store Connect asks; this app uses only standard TLS.
39
+ ITSAppUsesNonExemptEncryption: false,
40
+ },
41
+ },
42
+ android: {
43
+ package: bundleId,
44
+ adaptiveIcon: {
45
+ foregroundImage: "./assets/adaptive-icon.png",
46
+ backgroundColor: "#18181b",
47
+ },
48
+ },
49
+ plugins: [
50
+ "expo-router",
51
+ "expo-apple-authentication",
52
+ [
53
+ "expo-splash-screen",
54
+ { image: "./assets/splash-icon.png", resizeMode: "contain", backgroundColor: "#18181b" },
55
+ ],
56
+ [
57
+ "expo-build-properties",
58
+ {
59
+ ios: { deploymentTarget: "15.1" },
60
+ // Cleartext lets a dev/preview build reach http://localhost:4321.
61
+ android: { minSdkVersion: 24, usesCleartextTraffic: variant !== "production" },
62
+ },
63
+ ],
64
+ ],
65
+ experiments: { typedRoutes: true },
66
+ extra: {
67
+ eas: { projectId: process.env.EAS_PROJECT_ID },
68
+ variant,
69
+ },
70
+ });
@@ -0,0 +1,8 @@
1
+ module.exports = function (api) {
2
+ api.cache(true);
3
+ return {
4
+ presets: ["babel-preset-expo"],
5
+ // Reanimated's plugin must be last.
6
+ plugins: ["react-native-reanimated/plugin"],
7
+ };
8
+ };
@@ -0,0 +1,45 @@
1
+ {
2
+ "cli": {
3
+ "version": ">= 16.0.0",
4
+ "appVersionSource": "remote"
5
+ },
6
+ "build": {
7
+ "development": {
8
+ "developmentClient": true,
9
+ "distribution": "internal",
10
+ "env": {
11
+ "APP_VARIANT": "development",
12
+ "EXPO_PUBLIC_PYLON_BASE_URL": "http://localhost:4321"
13
+ },
14
+ "ios": { "simulator": true }
15
+ },
16
+ "device": {
17
+ "extends": "development",
18
+ "ios": { "simulator": false }
19
+ },
20
+ "preview": {
21
+ "distribution": "internal",
22
+ "env": {
23
+ "APP_VARIANT": "preview",
24
+ "EXPO_PUBLIC_PYLON_BASE_URL": "https://__APP_NAME_KEBAB__.pylonapp.com"
25
+ }
26
+ },
27
+ "production": {
28
+ "autoIncrement": true,
29
+ "env": {
30
+ "APP_VARIANT": "production",
31
+ "EXPO_PUBLIC_PYLON_BASE_URL": "https://__APP_NAME_KEBAB__.pylonapp.com"
32
+ }
33
+ }
34
+ },
35
+ "submit": {
36
+ "production": {
37
+ "ios": {
38
+ "ascAppId": "REPLACE_WITH_APP_STORE_CONNECT_APP_ID"
39
+ },
40
+ "android": {
41
+ "track": "internal"
42
+ }
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@__APP_NAME_KEBAB__/expo",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "main": "expo-router/entry",
6
+ "scripts": {
7
+ "start": "expo start",
8
+ "dev": "expo start",
9
+ "ios": "expo run:ios",
10
+ "android": "expo run:android",
11
+ "web": "expo start --web",
12
+ "check": "tsc --noEmit",
13
+ "build:dev": "eas build --profile development --platform ios",
14
+ "build:preview": "eas build --profile preview --platform all",
15
+ "build:prod": "eas build --profile production --platform all",
16
+ "submit:ios": "eas submit --platform ios --latest",
17
+ "submit:android": "eas submit --platform android --latest"
18
+ },
19
+ "dependencies": {
20
+ "@pylonsync/react": "^__PYLON_VERSION__",
21
+ "@pylonsync/react-native": "^__PYLON_VERSION__",
22
+ "@pylonsync/sdk": "^__PYLON_VERSION__",
23
+ "@pylonsync/sync": "^__PYLON_VERSION__",
24
+ "@react-native-async-storage/async-storage": "2.2.0",
25
+ "@react-native-community/netinfo": "12.0.1",
26
+ "@react-native-google-signin/google-signin": "^16.1.5",
27
+ "expo": "~57.0.20",
28
+ "expo-apple-authentication": "~57.0.1",
29
+ "expo-build-properties": "~57.0.17",
30
+ "expo-constants": "~57.0.17",
31
+ "expo-haptics": "~57.0.2",
32
+ "expo-linking": "~57.0.9",
33
+ "expo-router": "~57.0.19",
34
+ "expo-splash-screen": "~57.0.8",
35
+ "expo-status-bar": "~57.0.1",
36
+ "expo-web-browser": "~57.0.2",
37
+ "react": "19.2.3",
38
+ "react-dom": "19.2.3",
39
+ "react-native": "0.86.3",
40
+ "react-native-gesture-handler": "~2.32.0",
41
+ "react-native-purchases": "^10.9.0",
42
+ "react-native-reanimated": "4.5.1",
43
+ "react-native-safe-area-context": "~5.7.0",
44
+ "react-native-screens": "~4.26.0",
45
+ "react-native-web": "~0.21.0"
46
+ },
47
+ "devDependencies": {
48
+ "@babel/core": "^7.25.0",
49
+ "@types/react": "~19.2.0",
50
+ "typescript": "~5.9.0"
51
+ }
52
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Funnel events. Wire `track` to your analytics SDK (PostHog, Amplitude,
3
+ * Mixpanel) in one place; every screen already calls it with the event
4
+ * names below, so the onboarding → paywall → purchase funnel is measurable
5
+ * from the first build.
6
+ */
7
+ export type FunnelEvent =
8
+ | "onboarding_started"
9
+ | "onboarding_completed"
10
+ | "paywall_shown"
11
+ | "paywall_dismissed"
12
+ | "purchase_started"
13
+ | "purchase_completed"
14
+ | "purchase_restored"
15
+ | "sign_in_started"
16
+ | "sign_in_completed"
17
+ | "limit_reached"
18
+ | "account_deleted";
19
+
20
+ export function track(event: FunnelEvent, props: Record<string, unknown> = {}): void {
21
+ if (__DEV__) {
22
+ console.log(`[analytics] ${event}`, props);
23
+ }
24
+ // Example: posthog.capture(event, props);
25
+ }
@@ -0,0 +1,21 @@
1
+ import { db } from "@pylonsync/react-native";
2
+ import { hasEntitlement } from "@pylonsync/revenuecat";
3
+
4
+ export const PRO = "pro";
5
+
6
+ interface RcEntitlementRow {
7
+ id: string;
8
+ userId: string;
9
+ entitlement: string;
10
+ status: string;
11
+ expiresAt?: string | null;
12
+ }
13
+
14
+ /**
15
+ * Live Pro status from the synced RcEntitlement rows. Updates the moment
16
+ * the server writes the row after a purchase, on every device.
17
+ */
18
+ export function usePro(): { pro: boolean; loading: boolean } {
19
+ const { data, loading } = db.useQuery<RcEntitlementRow>("RcEntitlement", {});
20
+ return { pro: hasEntitlement(data ?? [], PRO), loading };
21
+ }
@@ -0,0 +1,31 @@
1
+ import AsyncStorage from "@react-native-async-storage/async-storage";
2
+ import { APP_NAME } from "./pylon";
3
+
4
+ /** Small per-device flags that drive the first-run flow. */
5
+ const KEYS = {
6
+ onboardingDone: `${APP_NAME}:onboarding_done`,
7
+ paywallSeenAt: `${APP_NAME}:paywall_seen_at`,
8
+ } as const;
9
+
10
+ export async function getOnboardingDone(): Promise<boolean> {
11
+ return (await AsyncStorage.getItem(KEYS.onboardingDone)) === "1";
12
+ }
13
+
14
+ export async function setOnboardingDone(): Promise<void> {
15
+ await AsyncStorage.setItem(KEYS.onboardingDone, "1");
16
+ }
17
+
18
+ /** When the post-onboarding paywall was last shown, or null. */
19
+ export async function getPaywallSeenAt(): Promise<number | null> {
20
+ const raw = await AsyncStorage.getItem(KEYS.paywallSeenAt);
21
+ return raw ? Number(raw) : null;
22
+ }
23
+
24
+ export async function setPaywallSeenAt(at: number = Date.now()): Promise<void> {
25
+ await AsyncStorage.setItem(KEYS.paywallSeenAt, String(at));
26
+ }
27
+
28
+ /** Test helper and "Reset onboarding" in Settings. */
29
+ export async function resetFlags(): Promise<void> {
30
+ await AsyncStorage.multiRemove(Object.values(KEYS));
31
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * RevenueCat in-app purchases.
3
+ *
4
+ * `react-native-purchases` is a native module: present in a development
5
+ * build and in store builds, absent in Expo Go. Everything here degrades:
6
+ * in Expo Go `available()` is false and the paywall shows a placeholder.
7
+ *
8
+ * Flow: configure(key, appUserID = the Pylon user id) → purchase → the
9
+ * server verifies with RevenueCat (`syncEntitlements`) and writes the
10
+ * RcEntitlement row → it syncs to every device. The app gates on the
11
+ * synced row, never on the SDK's local cache.
12
+ */
13
+ import { Platform } from "react-native";
14
+ import { callFn } from "@pylonsync/react-native";
15
+ import { track } from "./analytics";
16
+
17
+ type PurchasesModule = typeof import("react-native-purchases").default;
18
+ type PurchasesPackage = import("react-native-purchases").PurchasesPackage;
19
+
20
+ let Purchases: PurchasesModule | null = null;
21
+ let configuredFor: string | null = null;
22
+
23
+ function apiKey(): string | undefined {
24
+ return Platform.OS === "ios"
25
+ ? process.env.EXPO_PUBLIC_REVENUECAT_IOS_KEY
26
+ : process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_KEY;
27
+ }
28
+
29
+ function load(): PurchasesModule | null {
30
+ if (Purchases) return Purchases;
31
+ try {
32
+ // A static import throws at bundle load in Expo Go.
33
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
34
+ Purchases = require("react-native-purchases").default as PurchasesModule;
35
+ return Purchases;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /** True when a key is set and the native module is present. */
42
+ export function available(): boolean {
43
+ return Boolean(apiKey()) && load() != null;
44
+ }
45
+
46
+ /** Configure once, then re-identify when the user changes (guest → account). */
47
+ export async function configure(userId: string): Promise<boolean> {
48
+ const key = apiKey();
49
+ const mod = load();
50
+ if (!key || !mod) return false;
51
+ try {
52
+ if (configuredFor === null) {
53
+ mod.configure({ apiKey: key, appUserID: userId });
54
+ } else if (configuredFor !== userId) {
55
+ await mod.logIn(userId);
56
+ }
57
+ configuredFor = userId;
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ export interface Offer {
65
+ id: string;
66
+ title: string;
67
+ price: string;
68
+ /** "monthly" | "annual" | "lifetime" | "weekly" | other */
69
+ period: string;
70
+ /** Free trial length when the store offers one, e.g. "7 days". */
71
+ trial?: string;
72
+ raw: PurchasesPackage;
73
+ }
74
+
75
+ function periodOf(pkg: PurchasesPackage): string {
76
+ switch (pkg.packageType) {
77
+ case "MONTHLY":
78
+ return "monthly";
79
+ case "ANNUAL":
80
+ return "annual";
81
+ case "LIFETIME":
82
+ return "lifetime";
83
+ case "WEEKLY":
84
+ return "weekly";
85
+ default:
86
+ return pkg.identifier;
87
+ }
88
+ }
89
+
90
+ /** The current offering's packages, mapped for the paywall. */
91
+ export async function offers(): Promise<Offer[]> {
92
+ const mod = load();
93
+ if (!mod) return [];
94
+ try {
95
+ const offerings = await mod.getOfferings();
96
+ const pkgs = offerings.current?.availablePackages ?? [];
97
+ return pkgs.map((p) => {
98
+ const intro = p.product.introPrice;
99
+ const trial =
100
+ intro && intro.price === 0
101
+ ? `${intro.periodNumberOfUnits} ${intro.periodUnit.toLowerCase()}${intro.periodNumberOfUnits === 1 ? "" : "s"}`
102
+ : undefined;
103
+ return {
104
+ id: p.identifier,
105
+ title: p.product.title,
106
+ price: p.product.priceString,
107
+ period: periodOf(p),
108
+ trial,
109
+ raw: p,
110
+ };
111
+ });
112
+ } catch {
113
+ return [];
114
+ }
115
+ }
116
+
117
+ /** Returns true on purchase, false when the user cancelled. Throws on error. */
118
+ export async function purchase(offer: Offer): Promise<boolean> {
119
+ const mod = load();
120
+ if (!mod) return false;
121
+ track("purchase_started", { package: offer.id });
122
+ try {
123
+ await mod.purchasePackage(offer.raw);
124
+ } catch (e) {
125
+ if ((e as { userCancelled?: boolean })?.userCancelled) return false;
126
+ throw e;
127
+ }
128
+ await syncEntitlements();
129
+ track("purchase_completed", { package: offer.id });
130
+ return true;
131
+ }
132
+
133
+ export async function restore(): Promise<boolean> {
134
+ const mod = load();
135
+ if (!mod) return false;
136
+ try {
137
+ await mod.restorePurchases();
138
+ } catch {
139
+ return false;
140
+ }
141
+ await syncEntitlements();
142
+ track("purchase_restored");
143
+ return true;
144
+ }
145
+
146
+ /**
147
+ * Ask the server to re-read this user's entitlements from RevenueCat and
148
+ * write the rows. Makes the purchasing device unlock immediately and
149
+ * covers local dev, where the webhook cannot reach the machine.
150
+ */
151
+ export async function syncEntitlements(): Promise<void> {
152
+ try {
153
+ await callFn("syncEntitlements", {});
154
+ } catch {
155
+ // The webhook will land the row; nothing to do here.
156
+ }
157
+ }
158
+
159
+ /** Manage-subscription deep link for the current store. */
160
+ export function manageSubscriptionUrl(): string {
161
+ return Platform.OS === "ios"
162
+ ? "https://apps.apple.com/account/subscriptions"
163
+ : "https://play.google.com/store/account/subscriptions";
164
+ }
@@ -0,0 +1,23 @@
1
+ import { Platform } from "react-native";
2
+ import { init } from "@pylonsync/react-native";
3
+
4
+ /** The backend URL. Android emulators reach the host machine at 10.0.2.2. */
5
+ export const PYLON_BASE_URL =
6
+ process.env.EXPO_PUBLIC_PYLON_BASE_URL ??
7
+ (Platform.OS === "android" ? "http://10.0.2.2:4321" : "http://localhost:4321");
8
+
9
+ export const APP_NAME = "__APP_NAME_SNAKE__";
10
+
11
+ let initPromise: Promise<void> | null = null;
12
+
13
+ /**
14
+ * Boot the sync engine once. Reads the persisted token and replica from
15
+ * AsyncStorage, so a cold launch renders the last known data before the
16
+ * network answers.
17
+ */
18
+ export function ensurePylon(): Promise<void> {
19
+ if (!initPromise) {
20
+ initPromise = init({ baseUrl: PYLON_BASE_URL, appName: APP_NAME });
21
+ }
22
+ return initPromise;
23
+ }