@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { stripe } from "@pylonsync/stripe";
|
|
2
|
+
import { TRIAL_DAYS } from "./plans";
|
|
2
3
|
|
|
3
4
|
// Per-workspace (org) billing. The active org IS the billing reference: its
|
|
4
5
|
// subscription decides the plan, and only owners/admins can change it (the
|
|
@@ -9,17 +10,23 @@ import { stripe } from "@pylonsync/stripe";
|
|
|
9
10
|
// STRIPE_SECRET_KEY sk_test_… / sk_live_… (required to charge)
|
|
10
11
|
// STRIPE_WEBHOOK_SECRET whsec_… (required for the webhook)
|
|
11
12
|
// STRIPE_PRICE_PRO price_… (the "pro" monthly price)
|
|
13
|
+
// STRIPE_PRICE_PRO_ANNUAL price_… (the "pro" yearly price)
|
|
12
14
|
// Until STRIPE_SECRET_KEY is set the handlers return STRIPE_NOT_CONFIGURED and
|
|
13
15
|
// the Billing page shows a "connect Stripe" state — the app still boots + runs.
|
|
16
|
+
//
|
|
17
|
+
// Prices and the trial length are shown to customers from lib/plans.ts; keep
|
|
18
|
+
// the Stripe prices and that catalog in agreement.
|
|
14
19
|
export const billing = stripe({
|
|
15
20
|
referenceType: "org",
|
|
16
21
|
plans: [
|
|
17
22
|
{
|
|
18
23
|
name: "pro",
|
|
19
24
|
priceId: process.env.STRIPE_PRICE_PRO ?? "",
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
|
|
25
|
+
annualPriceId: process.env.STRIPE_PRICE_PRO_ANNUAL || undefined,
|
|
26
|
+
// Card up front, no charge until the trial ends. Stripe allows one
|
|
27
|
+
// trial per customer; the plugin refuses a second.
|
|
28
|
+
freeTrial: { days: TRIAL_DAYS },
|
|
29
|
+
limits: { projects: -1, seats: -1 },
|
|
23
30
|
},
|
|
24
31
|
],
|
|
25
32
|
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The plan catalog. One place for prices, the free-tier cap, the trial
|
|
2
|
+
// length, and the feature bullets, read by the marketing pricing page, the
|
|
3
|
+
// Billing tab, the Stripe plugin config (lib/billing.ts), and the server
|
|
4
|
+
// function that enforces the cap (functions/createProject.ts).
|
|
5
|
+
|
|
6
|
+
/** Active projects a free workspace can have. `createProject` enforces it. */
|
|
7
|
+
export const FREE_PROJECT_LIMIT = 3;
|
|
8
|
+
|
|
9
|
+
/** Days of free Pro on the first subscription. Stripe collects a card up front. */
|
|
10
|
+
export const TRIAL_DAYS = 14;
|
|
11
|
+
|
|
12
|
+
export type PlanId = "free" | "pro";
|
|
13
|
+
|
|
14
|
+
export interface PlanDefinition {
|
|
15
|
+
id: PlanId;
|
|
16
|
+
name: string;
|
|
17
|
+
tagline: string;
|
|
18
|
+
/** USD per month, billed monthly. */
|
|
19
|
+
monthly: number;
|
|
20
|
+
/** USD per month when billed annually (null = no annual price). */
|
|
21
|
+
annualPerMonth: number | null;
|
|
22
|
+
features: string[];
|
|
23
|
+
cta: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const PLANS: PlanDefinition[] = [
|
|
27
|
+
{
|
|
28
|
+
id: "free",
|
|
29
|
+
name: "Free",
|
|
30
|
+
tagline: "For getting started.",
|
|
31
|
+
monthly: 0,
|
|
32
|
+
annualPerMonth: null,
|
|
33
|
+
features: [
|
|
34
|
+
`Up to ${FREE_PROJECT_LIMIT} active projects`,
|
|
35
|
+
"Unlimited members",
|
|
36
|
+
"Real-time collaboration",
|
|
37
|
+
"Community support",
|
|
38
|
+
],
|
|
39
|
+
cta: "Get started",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "pro",
|
|
43
|
+
name: "Pro",
|
|
44
|
+
tagline: "For teams shipping every week.",
|
|
45
|
+
monthly: 29,
|
|
46
|
+
annualPerMonth: 24,
|
|
47
|
+
features: [
|
|
48
|
+
"Unlimited projects",
|
|
49
|
+
"Unlimited members",
|
|
50
|
+
"Priority support",
|
|
51
|
+
"Everything we ship next",
|
|
52
|
+
],
|
|
53
|
+
cta: `Start ${TRIAL_DAYS}-day free trial`,
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export function planById(id: string): PlanDefinition | undefined {
|
|
58
|
+
return PLANS.find((p) => p.id === id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** "$29" / "$0". */
|
|
62
|
+
export function formatPrice(usd: number): string {
|
|
63
|
+
return `$${usd}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whole-year price for the annual option, e.g. 24 → 288. */
|
|
67
|
+
export function annualTotal(plan: PlanDefinition): number | null {
|
|
68
|
+
return plan.annualPerMonth == null ? null : plan.annualPerMonth * 12;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Percent saved by paying annually, rounded. */
|
|
72
|
+
export function annualSavingsPercent(plan: PlanDefinition): number {
|
|
73
|
+
if (plan.annualPerMonth == null || plan.monthly === 0) return 0;
|
|
74
|
+
return Math.round((1 - plan.annualPerMonth / plan.monthly) * 100);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Can a workspace on `plan` with `activeProjects` create one more?
|
|
79
|
+
* Pure so the cap is unit-tested; `createProject` calls it.
|
|
80
|
+
*/
|
|
81
|
+
export function canCreateProject(plan: PlanId, activeProjects: number): boolean {
|
|
82
|
+
if (plan === "pro") return true;
|
|
83
|
+
return activeProjects < FREE_PROJECT_LIMIT;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A subscription counts as Pro while Stripe reports it usable. */
|
|
87
|
+
export const ACTIVE_STATUSES = ["active", "trialing", "past_due"];
|
|
88
|
+
|
|
89
|
+
export function planFromSubscription(
|
|
90
|
+
sub: { plan?: string; status?: string } | null | undefined,
|
|
91
|
+
): PlanId {
|
|
92
|
+
if (!sub) return "free";
|
|
93
|
+
return ACTIVE_STATUSES.includes(sub.status ?? "") && sub.plan === "pro" ? "pro" : "free";
|
|
94
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PLANS, formatPrice } from "./plans";
|
|
1
2
|
// Business-specific copy and settings live here. The marketing components,
|
|
2
3
|
// `create-pylon` scaffolder, and automated generators all read this file.
|
|
3
4
|
//
|
|
@@ -280,51 +281,18 @@ export const siteConfig: SiteConfig = {
|
|
|
280
281
|
pricing: {
|
|
281
282
|
eyebrow: "Pricing",
|
|
282
283
|
headline: "Simple pricing. Every plan.",
|
|
283
|
-
body: "Start free
|
|
284
|
-
plans
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
"Real-time board",
|
|
296
|
-
"Community support",
|
|
297
|
-
],
|
|
298
|
-
},
|
|
299
|
-
{
|
|
300
|
-
name: "Team",
|
|
301
|
-
tagline: "For small teams.",
|
|
302
|
-
price: "$29",
|
|
303
|
-
unit: "/ month",
|
|
304
|
-
cta: "Start free trial",
|
|
305
|
-
featured: true,
|
|
306
|
-
features: [
|
|
307
|
-
"Unlimited projects",
|
|
308
|
-
"Roadmap and updates",
|
|
309
|
-
"Private boards",
|
|
310
|
-
"Priority support",
|
|
311
|
-
],
|
|
312
|
-
},
|
|
313
|
-
{
|
|
314
|
-
name: "Business",
|
|
315
|
-
tagline: "For growing teams.",
|
|
316
|
-
price: "$59",
|
|
317
|
-
unit: "/ month",
|
|
318
|
-
cta: "Start free trial",
|
|
319
|
-
featured: false,
|
|
320
|
-
features: [
|
|
321
|
-
"Everything in Team",
|
|
322
|
-
"SSO and audit log",
|
|
323
|
-
"Custom domain",
|
|
324
|
-
"Onboarding help",
|
|
325
|
-
],
|
|
326
|
-
},
|
|
327
|
-
],
|
|
284
|
+
body: "Start free. Try Pro free for two weeks, and pay yearly to save.",
|
|
285
|
+
// Derived from lib/plans.ts, the catalog the Billing tab and the server
|
|
286
|
+
// cap read, so marketing and billing can't disagree on a price.
|
|
287
|
+
plans: PLANS.map((p) => ({
|
|
288
|
+
name: p.name,
|
|
289
|
+
tagline: p.tagline,
|
|
290
|
+
price: formatPrice(p.monthly),
|
|
291
|
+
unit: p.monthly === 0 ? "forever" : "/ month",
|
|
292
|
+
cta: p.cta,
|
|
293
|
+
featured: p.id === "pro",
|
|
294
|
+
features: p.features,
|
|
295
|
+
})),
|
|
328
296
|
},
|
|
329
297
|
|
|
330
298
|
team: {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
FREE_PROJECT_LIMIT,
|
|
4
|
+
annualSavingsPercent,
|
|
5
|
+
annualTotal,
|
|
6
|
+
canCreateProject,
|
|
7
|
+
planById,
|
|
8
|
+
planFromSubscription,
|
|
9
|
+
} from "../lib/plans";
|
|
10
|
+
|
|
11
|
+
// Tier 1 (pure logic) — the cap and the plan derivation that
|
|
12
|
+
// functions/createProject.ts and the dashboard rely on.
|
|
13
|
+
|
|
14
|
+
test("free workspaces stop at the cap, pro never does", () => {
|
|
15
|
+
expect(canCreateProject("free", 0)).toBe(true);
|
|
16
|
+
expect(canCreateProject("free", FREE_PROJECT_LIMIT - 1)).toBe(true);
|
|
17
|
+
expect(canCreateProject("free", FREE_PROJECT_LIMIT)).toBe(false);
|
|
18
|
+
expect(canCreateProject("pro", 10_000)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("a subscription is pro only while Stripe reports it usable", () => {
|
|
22
|
+
expect(planFromSubscription(null)).toBe("free");
|
|
23
|
+
expect(planFromSubscription({ plan: "pro", status: "active" })).toBe("pro");
|
|
24
|
+
expect(planFromSubscription({ plan: "pro", status: "trialing" })).toBe("pro");
|
|
25
|
+
expect(planFromSubscription({ plan: "pro", status: "past_due" })).toBe("pro");
|
|
26
|
+
expect(planFromSubscription({ plan: "pro", status: "canceled" })).toBe("free");
|
|
27
|
+
expect(planFromSubscription({ plan: "enterprise", status: "active" })).toBe("free");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("annual pricing derives its total and saving from the catalog", () => {
|
|
31
|
+
const pro = planById("pro")!;
|
|
32
|
+
expect(annualTotal(pro)).toBe(pro.annualPerMonth! * 12);
|
|
33
|
+
expect(annualSavingsPercent(pro)).toBeGreaterThan(0);
|
|
34
|
+
expect(annualSavingsPercent(planById("free")!)).toBe(0);
|
|
35
|
+
});
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { Link, type NotFoundProps } from "@pylonsync/react";
|
|
3
|
-
import { Button } from "@/components/ui/button";
|
|
4
|
-
|
|
5
|
-
// `app/not-found.tsx` → rendered at HTTP 404 for any unmatched URL, and when a
|
|
6
|
-
// page calls `response.notFound()`.
|
|
7
|
-
//
|
|
8
|
-
// Give it real links. This boundary is also what an agent reads when it guesses
|
|
9
|
-
// a URL wrong: a markdown request for a missing page renders THIS file as
|
|
10
|
-
// markdown at 404, so the links below are how it finds its way back.
|
|
11
|
-
export default function NotFound(_props: NotFoundProps) {
|
|
12
|
-
return (
|
|
13
|
-
<div className="mx-auto flex min-h-[60vh] max-w-lg flex-col justify-center gap-6 px-4">
|
|
14
|
-
<section>
|
|
15
|
-
<h1 className="text-2xl font-semibold tracking-tight">Page not found</h1>
|
|
16
|
-
<p className="mt-2 text-muted-foreground">
|
|
17
|
-
That URL does not exist. Try one of these:
|
|
18
|
-
</p>
|
|
19
|
-
</section>
|
|
20
|
-
<ul className="space-y-2 text-sm">
|
|
21
|
-
<li>
|
|
22
|
-
<Link className="underline underline-offset-4" href="/">
|
|
23
|
-
Home
|
|
24
|
-
</Link>
|
|
25
|
-
</li>
|
|
26
|
-
<li>
|
|
27
|
-
<a className="underline underline-offset-4" href="/sitemap.xml">
|
|
28
|
-
Sitemap
|
|
29
|
-
</a>
|
|
30
|
-
</li>
|
|
31
|
-
<li>
|
|
32
|
-
<a className="underline underline-offset-4" href="/llms.txt">
|
|
33
|
-
llms.txt
|
|
34
|
-
</a>
|
|
35
|
-
</li>
|
|
36
|
-
</ul>
|
|
37
|
-
<div>
|
|
38
|
-
<Button asChild>
|
|
39
|
-
<Link href="/">Back to home</Link>
|
|
40
|
-
</Button>
|
|
41
|
-
</div>
|
|
42
|
-
</div>
|
|
43
|
-
);
|
|
44
|
-
}
|