@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
|
@@ -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)),
|
package/templates/saas/app.ts
CHANGED
|
@@ -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
|
|
161
|
-
//
|
|
162
|
-
//
|
|
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: "
|
|
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
|
-
//
|
|
183
|
-
// cancelSubscription / restoreSubscription / stripeWebhook)
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
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
|
+
});
|