@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
@@ -0,0 +1,101 @@
1
+ import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
2
+ import { db, guestSession, signOut as pylonSignOut, useSession } from "@pylonsync/react-native";
3
+ import { ensurePylon } from "./pylon";
4
+ import { getOnboardingDone, setOnboardingDone as persistOnboardingDone } from "./flags";
5
+ import { configure as configurePurchases } from "./purchases";
6
+
7
+ /**
8
+ * What the router needs to know:
9
+ *
10
+ * booting the engine and the on-device flags are still loading
11
+ * onboarding first launch: show the welcome slides
12
+ * ready a session exists (guest or account) and the tabs can render
13
+ * signedOut the user signed out explicitly; offer sign-in or a fresh guest
14
+ */
15
+ export type SessionState = "booting" | "onboarding" | "ready" | "signedOut";
16
+
17
+ interface SessionValue {
18
+ state: SessionState;
19
+ userId: string | null;
20
+ /** True while the session is an anonymous guest (no account yet). */
21
+ isGuest: boolean;
22
+ completeOnboarding: () => Promise<void>;
23
+ continueAsGuest: () => Promise<void>;
24
+ signOut: () => Promise<void>;
25
+ }
26
+
27
+ const Ctx = createContext<SessionValue | null>(null);
28
+
29
+ export function SessionProvider({ children }: { children: React.ReactNode }) {
30
+ const [booted, setBooted] = useState(false);
31
+ const [onboardingDone, setOnboardingDoneState] = useState(false);
32
+ const [signedOut, setSignedOut] = useState(false);
33
+ const session = useSession(db.sync);
34
+ const userId = session.userId;
35
+
36
+ useEffect(() => {
37
+ let alive = true;
38
+ void (async () => {
39
+ await ensurePylon();
40
+ const done = await getOnboardingDone();
41
+ if (!alive) return;
42
+ setOnboardingDoneState(done);
43
+ setBooted(true);
44
+ })();
45
+ return () => {
46
+ alive = false;
47
+ };
48
+ }, []);
49
+
50
+ // 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.
52
+ useEffect(() => {
53
+ if (userId) void configurePurchases(userId);
54
+ }, [userId]);
55
+
56
+ const continueAsGuest = useCallback(async () => {
57
+ await guestSession();
58
+ await db.sync.notifySessionChanged();
59
+ setSignedOut(false);
60
+ }, []);
61
+
62
+ const completeOnboarding = useCallback(async () => {
63
+ await persistOnboardingDone();
64
+ setOnboardingDoneState(true);
65
+ if (!db.sync.resolvedSession().userId) {
66
+ await continueAsGuest();
67
+ }
68
+ }, [continueAsGuest]);
69
+
70
+ const signOut = useCallback(async () => {
71
+ await pylonSignOut();
72
+ setSignedOut(true);
73
+ }, []);
74
+
75
+ const state: SessionState = !booted
76
+ ? "booting"
77
+ : !onboardingDone
78
+ ? "onboarding"
79
+ : userId && !signedOut
80
+ ? "ready"
81
+ : "signedOut";
82
+
83
+ const value = useMemo<SessionValue>(
84
+ () => ({
85
+ state,
86
+ userId,
87
+ isGuest: Boolean(userId?.startsWith("guest_")),
88
+ completeOnboarding,
89
+ continueAsGuest,
90
+ signOut,
91
+ }),
92
+ [state, userId, completeOnboarding, continueAsGuest, signOut],
93
+ );
94
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
95
+ }
96
+
97
+ export function useAppSession(): SessionValue {
98
+ const v = useContext(Ctx);
99
+ if (!v) throw new Error("useAppSession must be used inside <SessionProvider>");
100
+ return v;
101
+ }
@@ -0,0 +1,36 @@
1
+ import { useColorScheme } from "react-native";
2
+
3
+ export const palette = {
4
+ light: {
5
+ bg: "#ffffff",
6
+ surface: "#f4f4f5",
7
+ border: "#e4e4e7",
8
+ text: "#18181b",
9
+ muted: "#71717a",
10
+ brand: "#18181b",
11
+ onBrand: "#ffffff",
12
+ danger: "#dc2626",
13
+ success: "#16a34a",
14
+ },
15
+ dark: {
16
+ bg: "#09090b",
17
+ surface: "#18181b",
18
+ border: "#27272a",
19
+ text: "#fafafa",
20
+ muted: "#a1a1aa",
21
+ brand: "#fafafa",
22
+ onBrand: "#09090b",
23
+ danger: "#f87171",
24
+ success: "#4ade80",
25
+ },
26
+ } as const;
27
+
28
+ export type Theme = Record<keyof (typeof palette)["light"], string>;
29
+
30
+ export function useTheme(): Theme {
31
+ const scheme = useColorScheme();
32
+ return scheme === "dark" ? palette.dark : palette.light;
33
+ }
34
+
35
+ export const space = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32 } as const;
36
+ export const radius = { sm: 8, md: 12, lg: 16, pill: 999 } as const;
@@ -0,0 +1,187 @@
1
+ import React from "react";
2
+ import {
3
+ ActivityIndicator,
4
+ Pressable,
5
+ StyleSheet,
6
+ Text,
7
+ TextInput,
8
+ View,
9
+ type PressableProps,
10
+ type TextInputProps,
11
+ type TextStyle,
12
+ type ViewStyle,
13
+ } from "react-native";
14
+ import { SafeAreaView } from "react-native-safe-area-context";
15
+ import * as Haptics from "expo-haptics";
16
+ import { radius, space, useTheme } from "./theme";
17
+
18
+ /** Full-screen container with safe-area insets and the theme background. */
19
+ export function Screen({
20
+ children,
21
+ style,
22
+ padded = true,
23
+ }: {
24
+ children: React.ReactNode;
25
+ style?: ViewStyle;
26
+ padded?: boolean;
27
+ }) {
28
+ const t = useTheme();
29
+ return (
30
+ <SafeAreaView style={[{ flex: 1, backgroundColor: t.bg }, style]}>
31
+ <View style={{ flex: 1, paddingHorizontal: padded ? space.xl : 0 }}>{children}</View>
32
+ </SafeAreaView>
33
+ );
34
+ }
35
+
36
+ export function Title({ children, style }: { children: React.ReactNode; style?: TextStyle }) {
37
+ const t = useTheme();
38
+ return (
39
+ <Text style={[{ fontSize: 28, fontWeight: "700", letterSpacing: -0.5, color: t.text }, style]}>
40
+ {children}
41
+ </Text>
42
+ );
43
+ }
44
+
45
+ export function Body({ children, style, muted }: { children: React.ReactNode; style?: TextStyle; muted?: boolean }) {
46
+ const t = useTheme();
47
+ return (
48
+ <Text style={[{ fontSize: 16, lineHeight: 22, color: muted ? t.muted : t.text }, style]}>
49
+ {children}
50
+ </Text>
51
+ );
52
+ }
53
+
54
+ export function Caption({ children, style }: { children: React.ReactNode; style?: TextStyle }) {
55
+ const t = useTheme();
56
+ return <Text style={[{ fontSize: 13, lineHeight: 18, color: t.muted }, style]}>{children}</Text>;
57
+ }
58
+
59
+ type ButtonProps = PressableProps & {
60
+ title: string;
61
+ variant?: "primary" | "secondary" | "ghost" | "danger";
62
+ loading?: boolean;
63
+ icon?: React.ReactNode;
64
+ };
65
+
66
+ export function Button({ title, variant = "primary", loading, icon, disabled, onPress, style, ...rest }: ButtonProps) {
67
+ const t = useTheme();
68
+ const bg =
69
+ variant === "primary" ? t.brand : variant === "secondary" ? t.surface : "transparent";
70
+ const fg =
71
+ variant === "primary" ? t.onBrand : variant === "danger" ? t.danger : t.text;
72
+ return (
73
+ <Pressable
74
+ accessibilityRole="button"
75
+ disabled={disabled || loading}
76
+ onPress={(e) => {
77
+ void Haptics.selectionAsync();
78
+ onPress?.(e);
79
+ }}
80
+ style={({ pressed }) => [
81
+ styles.button,
82
+ { backgroundColor: bg, opacity: disabled ? 0.5 : pressed ? 0.85 : 1 },
83
+ variant === "secondary" && { borderWidth: 1, borderColor: t.border },
84
+ style as ViewStyle,
85
+ ]}
86
+ {...rest}
87
+ >
88
+ {loading ? (
89
+ <ActivityIndicator color={fg} />
90
+ ) : (
91
+ <View style={{ flexDirection: "row", alignItems: "center", gap: space.sm }}>
92
+ {icon}
93
+ <Text style={{ color: fg, fontSize: 16, fontWeight: "600" }}>{title}</Text>
94
+ </View>
95
+ )}
96
+ </Pressable>
97
+ );
98
+ }
99
+
100
+ export const Field = React.forwardRef<TextInput, TextInputProps>(function Field(props, ref) {
101
+ const t = useTheme();
102
+ return (
103
+ <TextInput
104
+ ref={ref}
105
+ placeholderTextColor={t.muted}
106
+ {...props}
107
+ style={[
108
+ styles.field,
109
+ { backgroundColor: t.surface, borderColor: t.border, color: t.text },
110
+ props.style,
111
+ ]}
112
+ />
113
+ );
114
+ });
115
+
116
+ export function Card({ children, style }: { children: React.ReactNode; style?: ViewStyle }) {
117
+ const t = useTheme();
118
+ return (
119
+ <View style={[styles.card, { backgroundColor: t.surface, borderColor: t.border }, style]}>
120
+ {children}
121
+ </View>
122
+ );
123
+ }
124
+
125
+ /** A settings-style row: label on the left, optional value or chevron on the right. */
126
+ export function Row({
127
+ label,
128
+ value,
129
+ onPress,
130
+ danger,
131
+ }: {
132
+ label: string;
133
+ value?: string;
134
+ onPress?: () => void;
135
+ danger?: boolean;
136
+ }) {
137
+ const t = useTheme();
138
+ return (
139
+ <Pressable
140
+ accessibilityRole={onPress ? "button" : undefined}
141
+ onPress={onPress}
142
+ disabled={!onPress}
143
+ style={({ pressed }) => [styles.row, { borderColor: t.border, opacity: pressed ? 0.7 : 1 }]}
144
+ >
145
+ <Text style={{ fontSize: 16, color: danger ? t.danger : t.text }}>{label}</Text>
146
+ <Text style={{ fontSize: 15, color: t.muted }}>{value ?? (onPress ? "›" : "")}</Text>
147
+ </Pressable>
148
+ );
149
+ }
150
+
151
+ export function Spacer({ h = space.lg }: { h?: number }) {
152
+ return <View style={{ height: h }} />;
153
+ }
154
+
155
+ export function Centered({ children }: { children: React.ReactNode }) {
156
+ return <View style={{ flex: 1, alignItems: "center", justifyContent: "center", gap: space.md }}>{children}</View>;
157
+ }
158
+
159
+ const styles = StyleSheet.create({
160
+ button: {
161
+ minHeight: 52,
162
+ paddingHorizontal: space.xl,
163
+ borderRadius: radius.md,
164
+ alignItems: "center",
165
+ justifyContent: "center",
166
+ },
167
+ field: {
168
+ minHeight: 52,
169
+ borderWidth: 1,
170
+ borderRadius: radius.md,
171
+ paddingHorizontal: space.lg,
172
+ fontSize: 16,
173
+ },
174
+ card: {
175
+ borderWidth: 1,
176
+ borderRadius: radius.lg,
177
+ padding: space.lg,
178
+ gap: space.sm,
179
+ },
180
+ row: {
181
+ minHeight: 52,
182
+ flexDirection: "row",
183
+ alignItems: "center",
184
+ justifyContent: "space-between",
185
+ borderBottomWidth: StyleSheet.hairlineWidth,
186
+ },
187
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "expo/tsconfig.base",
3
+ "compilerOptions": {
4
+ "strict": true,
5
+ "paths": {
6
+ "@/*": ["./src/*"]
7
+ }
8
+ },
9
+ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
10
+ }
@@ -11,6 +11,8 @@
11
11
  STRIPE_SECRET_KEY=sk_test_...
12
12
  STRIPE_WEBHOOK_SECRET=whsec_...
13
13
  STRIPE_PRICE_PRO=price_...
14
+ # Optional yearly price for the annual toggle on /pricing and Billing.
15
+ STRIPE_PRICE_PRO_ANNUAL=price_...
14
16
 
15
17
  # ── OAuth (optional) ─────────────────────────────────────────────────────────
16
18
  # The login/signup pages show a Google button; set these to enable it.
@@ -24,14 +24,15 @@ app.ts User + Org/OrgMember/OrgInvite + Project + Stripe b
24
24
  app/page.tsx "/" — the server-rendered Acme landing page (auth-aware)
25
25
  app/layout.tsx marketing nav + footer (rebrand "Acme")
26
26
  app/{products,solutions,resources,company,compare}/[slug]/ data-driven marketing pages
27
- app/login,signup/ email/password + Google (POST /api/auth/password/*)
28
- app/onboarding/ first-run: create workspace → invite → first project
29
- app/dashboard/ "/dashboard" — authed; overview, projects, members, billing, settings
27
+ app/login,signup/ email/password, email code, Google; forgot/reset password
28
+ app/onboarding/ first-run wizard: workspace → invite → first project → plan (trial)
29
+ app/pricing/ monthly/annual plan table from lib/plans.ts
30
+ app/dashboard/ "/dashboard" — authed; overview + getting-started checklist, projects, members, billing, settings
30
31
  app/dashboard/dashboard-client.tsx the workspace client island
31
32
  app/{error,not-found}.tsx hydrated error + 404 boundaries
32
33
  app/{robots,sitemap}.ts /robots.txt + /sitemap.xml (enumerates every public page)
33
- functions/ Stripe checkout/portal/webhook handlers (one file per handler)
34
- lib/ products.ts + site.ts (marketing content), billing.ts (@pylonsync/stripe)
34
+ functions/ createProject (free-plan cap), onboarding + profile mutations, Stripe handlers
35
+ lib/ plans.ts (prices, cap, trial), billing.ts (@pylonsync/stripe), site.config.ts (copy)
35
36
  app/globals.css Tailwind v4 + shadcn tokens (compiled by Pylon)
36
37
  components/ui/ shadcn primitives (Button, Card)
37
38
  ```
@@ -70,9 +71,12 @@ tenant's rows. `db.useQuery` is live; `db.insert` is optimistic.
70
71
  - **Add tenant data:** new `entity()` with an `orgId` + the same two policy
71
72
  lines — a new tenant-scoped table, typed client and REST/realtime API included.
72
73
  - **Add a route:** drop `app/about/page.tsx` and visit `/about`.
73
- - **Enable billing:** set `STRIPE_SECRET_KEY` + `STRIPE_PRICE_PRO` (see
74
- `.env.example`); the Billing tab then runs real Stripe Checkout + Customer
75
- Portal, kept in sync by the `/api/fn/stripeWebhook` handler.
74
+ - **Change prices or the free cap:** edit `lib/plans.ts`. The pricing page, the
75
+ Billing tab, and the server-side cap in `functions/createProject.ts` all read it.
76
+ - **Enable billing:** set `STRIPE_SECRET_KEY`, `STRIPE_PRICE_PRO`, and
77
+ `STRIPE_PRICE_PRO_ANNUAL` (see `.env.example`); the Billing tab then runs real
78
+ Stripe Checkout (with the trial) + Customer Portal, kept in sync by the
79
+ `/api/fn/stripeWebhook` handler.
76
80
 
77
81
  ## Deploy
78
82