@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.
- 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
|
@@ -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
|
+
});
|
|
@@ -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.
|
package/templates/saas/README.md
CHANGED
|
@@ -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
|
|
28
|
-
app/onboarding/ first-run:
|
|
29
|
-
app/
|
|
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/
|
|
34
|
-
lib/
|
|
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
|
-
- **
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|