@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.
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
@@ -2,6 +2,7 @@ import React, { use } from "react";
2
2
  import { type Metadata, type PageProps } from "@pylonsync/react";
3
3
  import {
4
4
  Settings,
5
+ type AccountInfo,
5
6
  type OrgInfo,
6
7
  type OrgMemberRow,
7
8
  } from "../dashboard-client";
@@ -21,6 +22,7 @@ export default function SettingsPage({ auth, response, serverData }: PageProps)
21
22
  return null;
22
23
  }
23
24
  const org = use(serverData.get<OrgInfo>("Org", auth.tenant_id));
25
+ const me = use(serverData.get<AccountInfo>("User", auth.user_id!));
24
26
  const members = use(serverData.list<OrgMemberRow>("OrgMember"));
25
27
  const memberCount = members.filter(
26
28
  (m) => m.orgId === auth.tenant_id,
@@ -30,6 +32,7 @@ export default function SettingsPage({ auth, response, serverData }: PageProps)
30
32
  org={org}
31
33
  role={auth.roles?.[0] ?? ""}
32
34
  memberCount={memberCount}
35
+ me={me}
33
36
  />
34
37
  );
35
38
  }
@@ -0,0 +1,250 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import { callFn, db } from "@pylonsync/react";
5
+ import { createInvite, createOrg, renameOrg } from "@pylonsync/client";
6
+ import { Button } from "@/components/ui/button";
7
+ import { TRIAL_DAYS, planById } from "@/lib/plans";
8
+
9
+ type Step = "workspace" | "team" | "project" | "plan";
10
+ const ORDER: Step[] = ["workspace", "team", "project", "plan"];
11
+
12
+ const inputCls =
13
+ "h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-zinc-900 focus:ring-2 focus:ring-zinc-900/10";
14
+
15
+ /**
16
+ * Four short steps, each one decision, each skippable after the first:
17
+ *
18
+ * 1. Workspace name → creates the org and makes it the active tenant
19
+ * 2. Invite the team → up to three emails, or skip
20
+ * 3. First project → so the dashboard is not empty on arrival
21
+ * 4. Plan → start the Pro trial (Stripe Checkout) or stay free
22
+ *
23
+ * The workspace is created at step 1, so a closed tab still leaves the user
24
+ * with a usable account; the dashboard sends them back here until step 4
25
+ * stamps `Org.onboardedAt`.
26
+ */
27
+ export function OnboardingWizard({
28
+ org,
29
+ email,
30
+ }: {
31
+ org: { id: string; name: string } | null;
32
+ email: string;
33
+ }) {
34
+ const [orgId, setOrgId] = useState<string | null>(org?.id ?? null);
35
+ const [step, setStep] = useState<Step>("workspace");
36
+ const [name, setName] = useState(org?.name ?? suggestName(email));
37
+ const [emails, setEmails] = useState(["", "", ""]);
38
+ const [project, setProject] = useState("");
39
+ const [busy, setBusy] = useState(false);
40
+ const [error, setError] = useState<string | null>(null);
41
+ const index = ORDER.indexOf(step);
42
+
43
+ async function run(fn: () => Promise<void>) {
44
+ setBusy(true);
45
+ setError(null);
46
+ try {
47
+ await fn();
48
+ } catch (e) {
49
+ setError(e instanceof Error ? e.message : String(e));
50
+ } finally {
51
+ setBusy(false);
52
+ }
53
+ }
54
+
55
+ function saveWorkspace(e: React.FormEvent) {
56
+ e.preventDefault();
57
+ const value = name.trim();
58
+ if (!value) return;
59
+ void run(async () => {
60
+ if (orgId) {
61
+ await renameOrg(orgId, value);
62
+ } else {
63
+ const created = await createOrg(value);
64
+ setOrgId(created.id);
65
+ await db.sync.selectOrg(created.id);
66
+ }
67
+ setStep("team");
68
+ });
69
+ }
70
+
71
+ function sendInvites() {
72
+ const list = emails.map((x) => x.trim()).filter((x) => x.includes("@"));
73
+ void run(async () => {
74
+ if (orgId) {
75
+ for (const address of list) await createInvite(orgId, address, "member");
76
+ }
77
+ setStep("project");
78
+ });
79
+ }
80
+
81
+ function createFirstProject(e: React.FormEvent) {
82
+ e.preventDefault();
83
+ const value = project.trim();
84
+ if (!value || !orgId) return;
85
+ void run(async () => {
86
+ await callFn("createProject", { orgId, name: value });
87
+ setStep("plan");
88
+ });
89
+ }
90
+
91
+ async function finish(startTrial: boolean) {
92
+ if (!orgId) return;
93
+ await run(async () => {
94
+ await callFn("completeOnboarding", { orgId });
95
+ if (startTrial) {
96
+ const origin = window.location.origin;
97
+ const res = await callFn<{ url: string }>("createCheckoutSession", {
98
+ plan: "pro",
99
+ referenceId: orgId,
100
+ annual: true,
101
+ successUrl: `${origin}/dashboard?welcome=1`,
102
+ cancelUrl: `${origin}/dashboard/billing`,
103
+ });
104
+ window.location.assign(res.url);
105
+ return;
106
+ }
107
+ window.location.assign("/dashboard?welcome=1");
108
+ });
109
+ }
110
+
111
+ const pro = planById("pro")!;
112
+
113
+ return (
114
+ <div className="flex min-h-screen items-center justify-center bg-zinc-50 px-6 py-12">
115
+ <div className="w-full max-w-md">
116
+ <div className="mb-6 flex items-center gap-2">
117
+ {ORDER.map((s, i) => (
118
+ <span
119
+ key={s}
120
+ className={`h-1.5 flex-1 rounded-full ${i <= index ? "bg-zinc-900" : "bg-zinc-200"}`}
121
+ />
122
+ ))}
123
+ </div>
124
+ <div className="rounded-2xl border border-zinc-200 bg-white p-8">
125
+ {step === "workspace" && (
126
+ <form onSubmit={saveWorkspace} className="space-y-5">
127
+ <div>
128
+ <h1 className="text-xl font-semibold tracking-tight text-zinc-900">Name your workspace</h1>
129
+ <p className="mt-1 text-sm text-zinc-500">Usually your company or team name. You can change it later.</p>
130
+ </div>
131
+ <input
132
+ autoFocus
133
+ value={name}
134
+ onChange={(e) => setName(e.target.value)}
135
+ placeholder="Acme Inc"
136
+ aria-label="Workspace name"
137
+ className={inputCls}
138
+ />
139
+ <Button type="submit" className="w-full" disabled={busy || !name.trim()}>
140
+ {busy ? "…" : "Continue"}
141
+ </Button>
142
+ </form>
143
+ )}
144
+
145
+ {step === "team" && (
146
+ <div className="space-y-5">
147
+ <div>
148
+ <h1 className="text-xl font-semibold tracking-tight text-zinc-900">Invite your team</h1>
149
+ <p className="mt-1 text-sm text-zinc-500">Teammates get an email with a link to join. Add more later from Members.</p>
150
+ </div>
151
+ <div className="space-y-2">
152
+ {emails.map((value, i) => (
153
+ <input
154
+ key={i}
155
+ type="email"
156
+ value={value}
157
+ onChange={(e) => setEmails(emails.map((x, j) => (j === i ? e.target.value : x)))}
158
+ placeholder={i === 0 ? "teammate@company.com" : "Another email (optional)"}
159
+ aria-label={`Invite email ${i + 1}`}
160
+ className={inputCls}
161
+ />
162
+ ))}
163
+ </div>
164
+ <div className="flex gap-2">
165
+ <Button type="button" variant="outline" className="flex-1" onClick={() => setStep("project")} disabled={busy}>
166
+ Skip for now
167
+ </Button>
168
+ <Button type="button" className="flex-1" onClick={sendInvites} disabled={busy}>
169
+ {busy ? "…" : "Send invites"}
170
+ </Button>
171
+ </div>
172
+ </div>
173
+ )}
174
+
175
+ {step === "project" && (
176
+ <form onSubmit={createFirstProject} className="space-y-5">
177
+ <div>
178
+ <h1 className="text-xl font-semibold tracking-tight text-zinc-900">Create your first project</h1>
179
+ <p className="mt-1 text-sm text-zinc-500">Something you are working on this week. It shows up on your dashboard.</p>
180
+ </div>
181
+ <input
182
+ autoFocus
183
+ value={project}
184
+ onChange={(e) => setProject(e.target.value)}
185
+ placeholder="Website redesign"
186
+ aria-label="Project name"
187
+ className={inputCls}
188
+ />
189
+ <div className="flex gap-2">
190
+ <Button type="button" variant="outline" className="flex-1" onClick={() => setStep("plan")} disabled={busy}>
191
+ Skip
192
+ </Button>
193
+ <Button type="submit" className="flex-1" disabled={busy || !project.trim()}>
194
+ {busy ? "…" : "Create project"}
195
+ </Button>
196
+ </div>
197
+ </form>
198
+ )}
199
+
200
+ {step === "plan" && (
201
+ <div className="space-y-5">
202
+ <div>
203
+ <h1 className="text-xl font-semibold tracking-tight text-zinc-900">Pick a plan</h1>
204
+ <p className="mt-1 text-sm text-zinc-500">
205
+ Try Pro free for {TRIAL_DAYS} days. Cancel any time from Billing; you keep the free plan.
206
+ </p>
207
+ </div>
208
+ <ul className="space-y-2 rounded-xl border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-700">
209
+ {pro.features.map((f) => (
210
+ <li key={f} className="flex gap-2">
211
+ <span className="text-zinc-900">✓</span> {f}
212
+ </li>
213
+ ))}
214
+ </ul>
215
+ <Button type="button" className="w-full" onClick={() => void finish(true)} disabled={busy}>
216
+ {busy ? "…" : pro.cta}
217
+ </Button>
218
+ <button
219
+ type="button"
220
+ onClick={() => void finish(false)}
221
+ disabled={busy}
222
+ className="w-full text-center text-[13px] text-zinc-500 underline underline-offset-2 hover:text-zinc-900"
223
+ >
224
+ Continue on the free plan
225
+ </button>
226
+ </div>
227
+ )}
228
+
229
+ {error && (
230
+ <p className="mt-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[13px] text-red-700">
231
+ {error.replace(/^[A-Z_]+:\s*/, "")}
232
+ </p>
233
+ )}
234
+ </div>
235
+ <p className="mt-4 text-center text-xs text-zinc-400">
236
+ Step {index + 1} of {ORDER.length}
237
+ </p>
238
+ </div>
239
+ </div>
240
+ );
241
+ }
242
+
243
+ /** "jane@northwind.com" → "Northwind". A starting point the user can edit. */
244
+ function suggestName(email: string): string {
245
+ const domain = email.split("@")[1]?.split(".")[0] ?? "";
246
+ if (!domain || ["gmail", "yahoo", "outlook", "hotmail", "icloud", "proton", "me"].includes(domain)) {
247
+ return "";
248
+ }
249
+ return domain.charAt(0).toUpperCase() + domain.slice(1);
250
+ }
@@ -0,0 +1,34 @@
1
+ import React, { use } from "react";
2
+ import { type Metadata, type PageProps } from "@pylonsync/react";
3
+ import { OnboardingWizard } from "./onboarding-client";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "Set up your workspace — Acme",
7
+ robots: "noindex",
8
+ };
9
+
10
+ // `/onboarding` — the first-run wizard: name the workspace, invite the team,
11
+ // create the first project, pick a plan. Signed-out visitors get a real 307
12
+ // to /signup. A workspace that already finished the wizard goes straight to
13
+ // the dashboard, so the URL is safe to revisit.
14
+ export default function OnboardingPage({ auth, response, serverData }: PageProps) {
15
+ if (!auth.user_id) {
16
+ response.redirect("/signup");
17
+ return null;
18
+ }
19
+ let org: { id: string; name: string; onboardedAt?: string | null } | null = null;
20
+ if (auth.tenant_id) {
21
+ org = use(
22
+ serverData.get<{ id: string; name: string; onboardedAt?: string | null }>(
23
+ "Org",
24
+ auth.tenant_id,
25
+ ),
26
+ );
27
+ if (org?.onboardedAt) {
28
+ response.redirect("/dashboard");
29
+ return null;
30
+ }
31
+ }
32
+ const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
33
+ return <OnboardingWizard org={org} email={me?.email ?? ""} />;
34
+ }
@@ -23,6 +23,7 @@ export default async function sitemap(): Promise<Sitemap> {
23
23
 
24
24
  return [
25
25
  { url: `${SITE}/`, changeFrequency: "weekly", priority: 1 },
26
+ { url: `${SITE}/pricing`, changeFrequency: "monthly", priority: 0.9 },
26
27
  ...collection("/products", PRODUCTS.map((p) => p.slug)),
27
28
  ...collection("/solutions", SOLUTIONS.map((s) => s.slug)),
28
29
  ...collection("/resources", RESOURCES.map((r) => r.slug)),
@@ -49,6 +49,11 @@ const Org = entity(
49
49
  // The billing plugin creates + stamps this on first checkout; server-only
50
50
  // so it never reaches the client.
51
51
  stripeCustomerId: field.string().serverOnly().optional(),
52
+ // First-run state. `onboardedAt` is stamped when the /onboarding wizard
53
+ // finishes (the dashboard sends a fresh workspace back there until then);
54
+ // `setupDismissedAt` hides the Overview's getting-started checklist.
55
+ onboardedAt: field.datetime().optional(),
56
+ setupDismissedAt: field.datetime().optional(),
52
57
  },
53
58
  { indexes: [{ name: "by_created_by", fields: ["createdBy"], unique: false }] },
54
59
  );
@@ -157,14 +162,14 @@ const orgInvitePolicy = policy({
157
162
  });
158
163
 
159
164
  // Projects are scoped to your ACTIVE tenant. `auth.tenantId == data.orgId`
160
- // gates read AND write — and because orgId is client-supplied at insert time
161
- // (not stamped later), checking it here means you can only create a project in
162
- // the org you've selected. Switch orgs → a different project list.
165
+ // gates reads, edits, and deletes. Inserts go through the `createProject`
166
+ // function instead, which enforces the free plan's project cap on the server
167
+ // (lib/plans.ts) — a client cannot skip the paywall by writing the row itself.
163
168
  const projectPolicy = policy({
164
169
  name: "project_tenant",
165
170
  entity: "Project",
166
171
  allowRead: "auth.tenantId == data.orgId",
167
- allowInsert: "auth.tenantId == data.orgId",
172
+ allowInsert: "false",
168
173
  allowUpdate: "auth.tenantId == data.orgId",
169
174
  allowDelete: "auth.tenantId == data.orgId",
170
175
  });
@@ -179,12 +184,15 @@ const manifest = buildManifest({
179
184
  version: "0.1.0",
180
185
  entities: [User, Org, OrgMember, OrgInvite, Project, ...billing.manifest.entities],
181
186
  queries: fns.queries,
182
- // Billing actions (createCheckoutSession / createBillingPortalSession /
183
- // cancelSubscription / restoreSubscription / stripeWebhook). The plugin also
184
- // declares getSubscription/listSubscriptions queries, but the dashboard reads
185
- // the StripeSubscription entity directly (it's client-readable via the
186
- // plugin's policy), so we don't wire those.
187
- actions: [...fns.actions, ...billing.manifest.actions],
187
+ // The billing actions (createCheckoutSession / createBillingPortalSession /
188
+ // cancelSubscription / restoreSubscription / stripeWebhook) are re-exported
189
+ // from functions/, so discovery already lists them; spreading
190
+ // `billing.manifest.actions` as well would register each name twice and
191
+ // the server refuses to boot on a duplicate. The plugin also declares
192
+ // getSubscription/listSubscriptions queries, but the dashboard reads the
193
+ // StripeSubscription entity directly (client-readable via the plugin's
194
+ // policy), so those aren't wired.
195
+ actions: fns.actions,
188
196
  policies: [
189
197
  userPolicy,
190
198
  orgPolicy,
@@ -0,0 +1,97 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import { PLANS, TRIAL_DAYS, annualSavingsPercent, formatPrice } from "@/lib/plans";
5
+
6
+ /**
7
+ * The plan cards with a monthly/annual switch. Used on the landing page's
8
+ * pricing section and on /pricing. Prices come from lib/plans.ts, the same
9
+ * catalog the Billing tab and the server cap read.
10
+ */
11
+ export function PricingTable({ signedIn }: { signedIn: boolean }) {
12
+ const [annual, setAnnual] = useState(true);
13
+ const savings = Math.max(...PLANS.map(annualSavingsPercent));
14
+ return (
15
+ <div>
16
+ <div className="flex items-center justify-center gap-3 text-sm">
17
+ <span className={annual ? "text-zinc-500" : "font-medium text-zinc-900"}>Monthly</span>
18
+ <button
19
+ type="button"
20
+ role="switch"
21
+ aria-checked={annual}
22
+ aria-label="Bill annually"
23
+ onClick={() => setAnnual((v) => !v)}
24
+ className={`relative h-6 w-11 rounded-full transition-colors ${annual ? "bg-zinc-900" : "bg-zinc-300"}`}
25
+ >
26
+ <span
27
+ className={`absolute top-0.5 size-5 rounded-full bg-white transition-transform ${annual ? "translate-x-5" : "translate-x-0.5"}`}
28
+ />
29
+ </button>
30
+ <span className={annual ? "font-medium text-zinc-900" : "text-zinc-500"}>
31
+ Annual{savings > 0 ? ` · save ${savings}%` : ""}
32
+ </span>
33
+ </div>
34
+
35
+ <div className="mt-10 grid gap-5 md:grid-cols-2">
36
+ {PLANS.map((p) => {
37
+ const featured = p.id === "pro";
38
+ const perMonth = annual && p.annualPerMonth != null ? p.annualPerMonth : p.monthly;
39
+ const href = signedIn
40
+ ? featured
41
+ ? `/dashboard/billing?upgrade=pro&interval=${annual ? "annual" : "monthly"}`
42
+ : "/dashboard"
43
+ : "/signup";
44
+ return (
45
+ <div
46
+ key={p.id}
47
+ className={`flex flex-col rounded-2xl border p-7 ${
48
+ featured
49
+ ? "border-zinc-900 bg-white shadow-[0_24px_60px_-30px_rgba(0,0,0,0.3)]"
50
+ : "border-zinc-200 bg-paper"
51
+ }`}
52
+ >
53
+ <div className="flex items-center justify-between">
54
+ <h3 className="text-base font-semibold">{p.name}</h3>
55
+ {featured && (
56
+ <span className="font-mono text-[10px] uppercase tracking-[0.12em] text-brand">
57
+ {TRIAL_DAYS}-day free trial
58
+ </span>
59
+ )}
60
+ </div>
61
+ <p className="mt-1 text-[13px] text-zinc-500">{p.tagline}</p>
62
+ <div className="mt-5 flex items-baseline gap-1">
63
+ <span className="text-4xl font-semibold tracking-tight">{formatPrice(perMonth)}</span>
64
+ <span className="text-[13px] text-zinc-500">
65
+ {p.monthly === 0 ? "forever" : `/ month${annual ? ", billed yearly" : ""}`}
66
+ </span>
67
+ </div>
68
+ <ul className="mt-6 flex-1 space-y-3 text-[14px] text-zinc-600">
69
+ {p.features.map((f) => (
70
+ <li key={f} className="flex gap-2.5">
71
+ <span className="mt-[3px] text-brand">✓</span>
72
+ {f}
73
+ </li>
74
+ ))}
75
+ </ul>
76
+ <div className="mt-7">
77
+ <a
78
+ href={href}
79
+ className={
80
+ featured
81
+ ? "inline-flex h-10 w-full items-center justify-center rounded-lg bg-zinc-900 text-sm font-medium text-white transition-colors hover:bg-zinc-700"
82
+ : "inline-flex h-10 w-full items-center justify-center rounded-lg border border-zinc-300 bg-white text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-50"
83
+ }
84
+ >
85
+ {signedIn && featured ? "Upgrade to Pro" : p.cta}
86
+ </a>
87
+ </div>
88
+ </div>
89
+ );
90
+ })}
91
+ </div>
92
+ <p className="mt-6 text-center text-xs text-zinc-400">
93
+ Prices in USD. The trial needs a card and converts to the plan you pick unless you cancel first.
94
+ </p>
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,83 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import { callFn } from "@pylonsync/react";
5
+ import { Check, X } from "lucide-react";
6
+
7
+ export interface SetupState {
8
+ orgId: string;
9
+ hasProject: boolean;
10
+ hasTeammate: boolean;
11
+ hasBilling: boolean;
12
+ canDismiss: boolean;
13
+ }
14
+
15
+ /**
16
+ * "Getting started" on the Overview. Items are derived from real data (a
17
+ * project exists, a teammate or pending invite exists, Pro is active), so the
18
+ * list ticks itself off; it disappears when all three are done or an
19
+ * owner/admin dismisses it for the workspace.
20
+ */
21
+ export function SetupChecklist({ state }: { state: SetupState }) {
22
+ const [hidden, setHidden] = useState(false);
23
+ const items = [
24
+ { done: state.hasProject, label: "Create a project", href: "/dashboard/projects" },
25
+ { done: state.hasTeammate, label: "Invite a teammate", href: "/dashboard/members" },
26
+ { done: state.hasBilling, label: "Start your Pro trial", href: "/dashboard/billing" },
27
+ ];
28
+ const remaining = items.filter((i) => !i.done).length;
29
+ if (hidden || remaining === 0) return null;
30
+
31
+ async function dismiss() {
32
+ setHidden(true);
33
+ try {
34
+ await callFn("dismissSetup", { orgId: state.orgId });
35
+ } catch {
36
+ // Hidden for this view regardless; the next load re-evaluates.
37
+ }
38
+ }
39
+
40
+ return (
41
+ <div className="rounded-xl border border-zinc-200 bg-white p-5">
42
+ <div className="flex items-center justify-between">
43
+ <div>
44
+ <h2 className="text-sm font-semibold text-zinc-900">Getting started</h2>
45
+ <p className="mt-0.5 text-xs text-zinc-500">
46
+ {items.length - remaining} of {items.length} done
47
+ </p>
48
+ </div>
49
+ {state.canDismiss && (
50
+ <button
51
+ type="button"
52
+ onClick={() => void dismiss()}
53
+ aria-label="Dismiss checklist"
54
+ className="flex size-7 items-center justify-center rounded-md text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700"
55
+ >
56
+ <X className="size-4" />
57
+ </button>
58
+ )}
59
+ </div>
60
+ <ul className="mt-3 divide-y divide-zinc-100">
61
+ {items.map((it) => (
62
+ <li key={it.label} className="flex items-center gap-3 py-2.5">
63
+ <span
64
+ className={
65
+ "flex size-5 shrink-0 items-center justify-center rounded-full border " +
66
+ (it.done ? "border-emerald-500 bg-emerald-500 text-white" : "border-zinc-300")
67
+ }
68
+ >
69
+ {it.done && <Check className="size-3" />}
70
+ </span>
71
+ {it.done ? (
72
+ <span className="text-sm text-zinc-400 line-through">{it.label}</span>
73
+ ) : (
74
+ <a href={it.href} className="text-sm font-medium text-zinc-900 hover:underline">
75
+ {it.label} →
76
+ </a>
77
+ )}
78
+ </li>
79
+ ))}
80
+ </ul>
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,15 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+
3
+ // Marks the workspace's first-run wizard as finished. Org rows are
4
+ // framework-managed and deny client writes, so the stamp goes through this
5
+ // owner/admin-gated function.
6
+ export default mutation<{ orgId: string }, { ok: true }>({
7
+ args: { orgId: v.string() },
8
+ async handler(ctx, args) {
9
+ await ctx.requireMember(args.orgId, { role: ["owner", "admin"] });
10
+ await ctx.db.unsafe.update("Org", args.orgId, {
11
+ onboardedAt: new Date().toISOString(),
12
+ });
13
+ return { ok: true };
14
+ },
15
+ });
@@ -0,0 +1,56 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { normalizeProjectName } from "../lib/projects";
3
+ import { canCreateProject, planFromSubscription } from "../lib/plans";
4
+
5
+ // createProject — the free-tier cap lives here, on the server. The Project
6
+ // policy denies client inserts, so a browser cannot skip the paywall by
7
+ // writing the row itself. Returns LIMIT_REACHED when the workspace is at the
8
+ // cap; the Projects tab opens the upgrade dialog on that code.
9
+ export default mutation<
10
+ { orgId: string; name: string; description?: string },
11
+ { id: string }
12
+ >({
13
+ args: {
14
+ orgId: v.string(),
15
+ name: v.string(),
16
+ description: v.optional(v.string()),
17
+ },
18
+ async handler(ctx, args) {
19
+ const name = normalizeProjectName(args.name);
20
+ if (!name) {
21
+ throw ctx.error("INVALID_ARGS", "Project name must be 1–80 characters.");
22
+ }
23
+ // Any member of the workspace may create a project. Fails closed.
24
+ await ctx.requireMember(args.orgId);
25
+
26
+ const subs = (await ctx.db.unsafe.list("StripeSubscription")) as Array<{
27
+ referenceId: string;
28
+ plan?: string;
29
+ status?: string;
30
+ }>;
31
+ const sub = subs.find((s) => s.referenceId === args.orgId) ?? null;
32
+ const plan = planFromSubscription(sub);
33
+
34
+ const projects = (await ctx.db.unsafe.list("Project")) as Array<{
35
+ orgId: string;
36
+ status?: string;
37
+ }>;
38
+ const active = projects.filter(
39
+ (p) => p.orgId === args.orgId && (p.status ?? "active") !== "archived",
40
+ ).length;
41
+ if (!canCreateProject(plan, active)) {
42
+ throw ctx.error(
43
+ "LIMIT_REACHED",
44
+ "This workspace is at the free plan's project limit. Upgrade to Pro for unlimited projects.",
45
+ );
46
+ }
47
+
48
+ const id = (await ctx.db.unsafe.insert("Project", {
49
+ orgId: args.orgId,
50
+ name,
51
+ status: "active",
52
+ ...(args.description?.trim() ? { description: args.description.trim() } : {}),
53
+ })) as string;
54
+ return { id };
55
+ },
56
+ });
@@ -0,0 +1,14 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+
3
+ // Hides the "Getting started" checklist on the Overview for everyone in the
4
+ // workspace. Owners and admins only, since it is a workspace-wide setting.
5
+ export default mutation<{ orgId: string }, { ok: true }>({
6
+ args: { orgId: v.string() },
7
+ async handler(ctx, args) {
8
+ await ctx.requireMember(args.orgId, { role: ["owner", "admin"] });
9
+ await ctx.db.unsafe.update("Org", args.orgId, {
10
+ setupDismissedAt: new Date().toISOString(),
11
+ });
12
+ return { ok: true };
13
+ },
14
+ });
@@ -0,0 +1,18 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+
3
+ // The one User field a person may edit about themselves. Email changes
4
+ // need re-verification and passwords have their own route, so the User
5
+ // policy denies client writes and this function owns display-name edits.
6
+ export default mutation<{ displayName: string }, { displayName: string }>({
7
+ args: { displayName: v.string() },
8
+ async handler(ctx, args) {
9
+ const userId = ctx.auth.userId;
10
+ if (!userId) throw ctx.error("UNAUTHENTICATED", "Sign in first.");
11
+ const displayName = args.displayName.trim();
12
+ if (displayName.length < 1 || displayName.length > 60) {
13
+ throw ctx.error("INVALID_ARGS", "Name must be 1–60 characters.");
14
+ }
15
+ await ctx.db.unsafe.update("User", userId, { displayName });
16
+ return { displayName };
17
+ },
18
+ });