@pylonsync/create-pylon 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/bin/create-pylon.js +10 -2
  2. package/package.json +1 -1
  3. package/templates/backend/mobile/apps/api/.env.example +22 -0
  4. package/templates/backend/mobile/apps/api/README.md +31 -0
  5. package/templates/backend/mobile/apps/api/app.ts +89 -0
  6. package/templates/backend/mobile/apps/api/functions/_pylonRcUpsertEntitlement.ts +1 -0
  7. package/templates/backend/mobile/apps/api/functions/createNote.ts +44 -0
  8. package/templates/backend/mobile/apps/api/functions/revenuecatWebhook.ts +1 -0
  9. package/templates/backend/mobile/apps/api/functions/syncEntitlements.ts +1 -0
  10. package/templates/backend/mobile/apps/api/lib/purchases.ts +22 -0
  11. package/templates/backend/mobile/apps/api/package.json +23 -0
  12. package/templates/backend/mobile/apps/api/tsconfig.json +13 -0
  13. package/templates/expo/mobile/apps/expo/.env.example +19 -0
  14. package/templates/expo/mobile/apps/expo/README.md +34 -0
  15. package/templates/expo/mobile/apps/expo/STORE.md +82 -0
  16. package/templates/expo/mobile/apps/expo/app/(auth)/_layout.tsx +6 -0
  17. package/templates/expo/mobile/apps/expo/app/(auth)/sign-in.tsx +194 -0
  18. package/templates/expo/mobile/apps/expo/app/(auth)/verify.tsx +78 -0
  19. package/templates/expo/mobile/apps/expo/app/(onboarding)/_layout.tsx +6 -0
  20. package/templates/expo/mobile/apps/expo/app/(onboarding)/welcome.tsx +125 -0
  21. package/templates/expo/mobile/apps/expo/app/(tabs)/_layout.tsx +27 -0
  22. package/templates/expo/mobile/apps/expo/app/(tabs)/index.tsx +128 -0
  23. package/templates/expo/mobile/apps/expo/app/(tabs)/settings.tsx +122 -0
  24. package/templates/expo/mobile/apps/expo/app/_layout.tsx +61 -0
  25. package/templates/expo/mobile/apps/expo/app/index.tsx +15 -0
  26. package/templates/expo/mobile/apps/expo/app/paywall.tsx +177 -0
  27. package/templates/expo/mobile/apps/expo/app.config.ts +70 -0
  28. package/templates/expo/mobile/apps/expo/assets/adaptive-icon.png +0 -0
  29. package/templates/expo/mobile/apps/expo/assets/icon.png +0 -0
  30. package/templates/expo/mobile/apps/expo/assets/splash-icon.png +0 -0
  31. package/templates/expo/mobile/apps/expo/babel.config.js +8 -0
  32. package/templates/expo/mobile/apps/expo/eas.json +45 -0
  33. package/templates/expo/mobile/apps/expo/package.json +52 -0
  34. package/templates/expo/mobile/apps/expo/src/analytics.ts +25 -0
  35. package/templates/expo/mobile/apps/expo/src/entitlements.ts +21 -0
  36. package/templates/expo/mobile/apps/expo/src/flags.ts +31 -0
  37. package/templates/expo/mobile/apps/expo/src/purchases.ts +164 -0
  38. package/templates/expo/mobile/apps/expo/src/pylon.ts +23 -0
  39. package/templates/expo/mobile/apps/expo/src/session.tsx +101 -0
  40. package/templates/expo/mobile/apps/expo/src/theme.ts +36 -0
  41. package/templates/expo/mobile/apps/expo/src/ui.tsx +187 -0
  42. package/templates/expo/mobile/apps/expo/tsconfig.json +10 -0
  43. package/templates/saas/.env.example +2 -0
  44. package/templates/saas/README.md +12 -8
  45. package/templates/saas/app/(auth)/auth-form.tsx +136 -95
  46. package/templates/saas/app/(auth)/forgot-password/page.tsx +20 -0
  47. package/templates/saas/app/(auth)/recovery-forms.tsx +135 -0
  48. package/templates/saas/app/(auth)/reset-password/page.tsx +24 -0
  49. package/templates/saas/app/(marketing)/layout.tsx +1 -1
  50. package/templates/saas/app/(marketing)/page.tsx +3 -52
  51. package/templates/saas/app/(marketing)/pricing/page.tsx +43 -0
  52. package/templates/saas/app/dashboard/dashboard-client.tsx +368 -18
  53. package/templates/saas/app/dashboard/page.tsx +30 -2
  54. package/templates/saas/app/dashboard/provision-workspace.tsx +10 -8
  55. package/templates/saas/app/dashboard/settings/page.tsx +3 -0
  56. package/templates/saas/app/onboarding/onboarding-client.tsx +250 -0
  57. package/templates/saas/app/onboarding/page.tsx +34 -0
  58. package/templates/saas/app/sitemap.ts +1 -0
  59. package/templates/saas/app.ts +18 -10
  60. package/templates/saas/components/pricing-table.tsx +97 -0
  61. package/templates/saas/components/setup-checklist.tsx +83 -0
  62. package/templates/saas/functions/completeOnboarding.ts +15 -0
  63. package/templates/saas/functions/createProject.ts +56 -0
  64. package/templates/saas/functions/dismissSetup.ts +14 -0
  65. package/templates/saas/functions/updateProfile.ts +18 -0
  66. package/templates/saas/lib/billing.ts +10 -3
  67. package/templates/saas/lib/plans.ts +94 -0
  68. package/templates/saas/lib/site.config.ts +13 -45
  69. package/templates/saas/tests/plans.test.ts +35 -0
  70. package/templates/saas/app/not-found.tsx +0 -44
@@ -0,0 +1,43 @@
1
+ import React from "react";
2
+ import { type Metadata, type PageProps } from "@pylonsync/react";
3
+ import { WRAP, Eyebrow, Divider } from "@/components/marketing";
4
+ import { PricingTable } from "@/components/pricing-table";
5
+ import { siteConfig } from "@/lib/site.config";
6
+ import { TRIAL_DAYS } from "@/lib/plans";
7
+
8
+ export const metadata: Metadata = {
9
+ title: `Pricing — ${siteConfig.brand.name}`,
10
+ description: `${siteConfig.brand.name} pricing: a free plan, and Pro with a ${TRIAL_DAYS}-day free trial.`,
11
+ };
12
+
13
+ // `/pricing`. Server-rendered so the copy is in the HTML; the monthly/annual
14
+ // switch is the one client island. Plan data comes from lib/plans.ts.
15
+ export default function PricingPage({ auth }: PageProps) {
16
+ const { pricing, faq } = siteConfig;
17
+ return (
18
+ <div className="bg-white text-zinc-900">
19
+ <section className={`${WRAP} pt-20 pb-16`}>
20
+ <Eyebrow>{pricing.eyebrow}</Eyebrow>
21
+ <h1 className="mt-4 max-w-xl text-balance text-4xl font-semibold leading-[1.1] tracking-[-0.02em] sm:text-5xl">
22
+ {pricing.headline}
23
+ </h1>
24
+ <p className="mt-5 max-w-md text-[15px] leading-relaxed text-zinc-500">{pricing.body}</p>
25
+ <div className="mt-12">
26
+ <PricingTable signedIn={Boolean(auth.user_id)} />
27
+ </div>
28
+ </section>
29
+ <Divider />
30
+ <section className={`${WRAP} py-16`}>
31
+ <h2 className="text-2xl font-semibold tracking-tight">Questions</h2>
32
+ <dl className="mt-8 grid gap-8 md:grid-cols-2">
33
+ {faq.items.map((f) => (
34
+ <div key={f.q}>
35
+ <dt className="text-sm font-semibold text-zinc-900">{f.q}</dt>
36
+ <dd className="mt-1.5 text-sm leading-relaxed text-zinc-500">{f.a}</dd>
37
+ </div>
38
+ ))}
39
+ </dl>
40
+ </section>
41
+ </div>
42
+ );
43
+ }
@@ -7,13 +7,31 @@ import {
7
7
  deleteOrg,
8
8
  listInvites,
9
9
  listOrgMembers,
10
+ removeMember,
10
11
  renameOrg,
11
12
  revokeInvite,
13
+ updateMemberRole,
12
14
  useAuth,
13
15
  type OrgMember,
14
16
  type PendingInvite,
15
17
  } from "@pylonsync/client";
16
18
  import { Button } from "@/components/ui/button";
19
+ import {
20
+ Dialog,
21
+ DialogContent,
22
+ DialogDescription,
23
+ DialogFooter,
24
+ DialogHeader,
25
+ DialogTitle,
26
+ } from "@/components/ui/dialog";
27
+ import { SetupChecklist, type SetupState } from "@/components/setup-checklist";
28
+ import {
29
+ FREE_PROJECT_LIMIT,
30
+ TRIAL_DAYS,
31
+ annualSavingsPercent,
32
+ formatPrice,
33
+ planById,
34
+ } from "@/lib/plans";
17
35
  import {
18
36
  FolderKanban,
19
37
  Users2,
@@ -188,6 +206,7 @@ export function Overview({
188
206
  projects,
189
207
  memberCount,
190
208
  plan,
209
+ setup,
191
210
  }: {
192
211
  tenantId: string | null;
193
212
  orgName?: string;
@@ -195,6 +214,8 @@ export function Overview({
195
214
  projects: Project[];
196
215
  memberCount: number;
197
216
  plan: string;
217
+ /** Getting-started state; null once the checklist is dismissed. */
218
+ setup?: SetupState | null;
198
219
  }) {
199
220
  if (!tenantId) return <NoOrg />;
200
221
  const activeCount = projects.filter(
@@ -218,6 +239,8 @@ export function Overview({
218
239
  </p>
219
240
  </div>
220
241
 
242
+ {setup ? <SetupChecklist state={setup} /> : null}
243
+
221
244
  <div className="grid gap-4 sm:grid-cols-3">
222
245
  <StatCard
223
246
  icon={<FolderKanban className="size-4" />}
@@ -331,6 +354,9 @@ function ProjectsList({
331
354
  }) {
332
355
  const [name, setName] = useState("");
333
356
  const [desc, setDesc] = useState("");
357
+ const [creating, setCreating] = useState(false);
358
+ const [limitHit, setLimitHit] = useState(false);
359
+ const [createError, setCreateError] = useState<string | null>(null);
334
360
  // Render the server-seeded `initial` rows on the server AND the client's
335
361
  // first paint, then swap to the live reactive query once mounted. Gating the
336
362
  // swap on a post-mount flag (rather than `loading`, which is already settled
@@ -351,19 +377,34 @@ function ProjectsList({
351
377
  return a.createdAt < b.createdAt ? 1 : -1;
352
378
  });
353
379
 
380
+ // Creates go through the `createProject` function: it enforces the free
381
+ // plan's cap on the server and answers LIMIT_REACHED at the cap, which
382
+ // opens the upgrade dialog. The new row arrives through sync.
354
383
  async function add(e: React.FormEvent) {
355
384
  e.preventDefault();
356
385
  const value = name.trim();
357
386
  if (!value) return;
358
387
  const description = desc.trim();
359
- setName("");
360
- setDesc("");
361
- await db.insert("Project", {
362
- orgId,
363
- name: value,
364
- status: "active",
365
- ...(description ? { description } : {}),
366
- });
388
+ setCreating(true);
389
+ setCreateError(null);
390
+ try {
391
+ await callFn("createProject", {
392
+ orgId,
393
+ name: value,
394
+ ...(description ? { description } : {}),
395
+ });
396
+ setName("");
397
+ setDesc("");
398
+ } catch (err) {
399
+ const msg = err instanceof Error ? err.message : String(err);
400
+ if (/LIMIT_REACHED/.test(msg) || (err as { code?: string })?.code === "LIMIT_REACHED") {
401
+ setLimitHit(true);
402
+ } else {
403
+ setCreateError(msg.replace(/^[A-Z_]+:\s*/, ""));
404
+ }
405
+ } finally {
406
+ setCreating(false);
407
+ }
367
408
  }
368
409
 
369
410
  return (
@@ -387,16 +428,22 @@ function ProjectsList({
387
428
  aria-label="Project description"
388
429
  className={inputCls + " sm:flex-1"}
389
430
  />
390
- <Button type="submit" size="sm" className="shrink-0">
391
- <Plus className="size-4" /> New project
431
+ <Button type="submit" size="sm" className="shrink-0" disabled={creating}>
432
+ <Plus className="size-4" /> {creating ? "Creating…" : "New project"}
392
433
  </Button>
393
434
  </form>
394
- <p className="mt-2 text-xs text-zinc-400">
395
- Tenant-scoped + live — only this workspace&apos;s projects (enforced by
396
- policy), and every change syncs across tabs instantly.
397
- </p>
435
+ {createError ? (
436
+ <p className="mt-2 text-xs text-red-600">{createError}</p>
437
+ ) : (
438
+ <p className="mt-2 text-xs text-zinc-400">
439
+ Tenant-scoped + live — only this workspace&apos;s projects (enforced by
440
+ policy), and every change syncs across tabs instantly.
441
+ </p>
442
+ )}
398
443
  </div>
399
444
 
445
+ <UpgradeDialog open={limitHit} onClose={() => setLimitHit(false)} />
446
+
400
447
  {projects.length === 0 ? (
401
448
  <div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-zinc-300 py-14 text-center">
402
449
  <span className="flex size-11 items-center justify-center rounded-xl bg-zinc-50 text-zinc-400">
@@ -418,6 +465,44 @@ function ProjectsList({
418
465
  );
419
466
  }
420
467
 
468
+ // Shown when `createProject` answers LIMIT_REACHED. One action: go to
469
+ // Billing, where the trial starts.
470
+ function UpgradeDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
471
+ const pro = planById("pro")!;
472
+ return (
473
+ <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
474
+ <DialogContent>
475
+ <DialogHeader>
476
+ <DialogTitle>You&apos;ve reached the free plan&apos;s limit</DialogTitle>
477
+ <DialogDescription>
478
+ Free workspaces can have {FREE_PROJECT_LIMIT} active projects. Archive one, or
479
+ upgrade to Pro for unlimited projects. Pro starts with a {TRIAL_DAYS}-day free
480
+ trial.
481
+ </DialogDescription>
482
+ </DialogHeader>
483
+ <ul className="space-y-1.5 text-sm text-zinc-700">
484
+ {pro.features.map((f) => (
485
+ <li key={f} className="flex gap-2">
486
+ <span className="text-zinc-900">✓</span> {f}
487
+ </li>
488
+ ))}
489
+ </ul>
490
+ <DialogFooter>
491
+ <Button type="button" variant="outline" onClick={onClose}>
492
+ Not now
493
+ </Button>
494
+ <a
495
+ href="/dashboard/billing?upgrade=pro&interval=annual"
496
+ className="inline-flex h-9 items-center rounded-lg bg-zinc-900 px-4 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700"
497
+ >
498
+ {pro.cta}
499
+ </a>
500
+ </DialogFooter>
501
+ </DialogContent>
502
+ </Dialog>
503
+ );
504
+ }
505
+
421
506
  function IconBtn({
422
507
  label,
423
508
  danger,
@@ -682,6 +767,31 @@ function MembersList({
682
767
  }
683
768
  }
684
769
 
770
+ async function changeRole(userId: string, next: string) {
771
+ setNote(null);
772
+ try {
773
+ await updateMemberRole(orgId, userId, next);
774
+ } catch {
775
+ setNote("Couldn't change that role.");
776
+ } finally {
777
+ void load();
778
+ }
779
+ }
780
+
781
+ async function remove(m: OrgMember) {
782
+ const label = m.name || m.email || "this member";
783
+ if (!window.confirm(`Remove ${label} from the workspace?`)) return;
784
+ setNote(null);
785
+ setMembers((prev) => prev?.filter((x) => x.user_id !== m.user_id) ?? null);
786
+ try {
787
+ await removeMember(orgId, m.user_id);
788
+ } catch {
789
+ setNote("Couldn't remove that member.");
790
+ } finally {
791
+ void load();
792
+ }
793
+ }
794
+
685
795
  return (
686
796
  <div className="space-y-6">
687
797
  <Card title="Members" action={members ? <Count n={members.length} /> : null}>
@@ -741,7 +851,28 @@ function MembersList({
741
851
  </div>
742
852
  )}
743
853
  </div>
744
- <RoleBadge role={m.role} />
854
+ {canManage && !isMe && m.role !== "owner" ? (
855
+ <>
856
+ <select
857
+ value={m.role}
858
+ onChange={(e) => void changeRole(m.user_id, e.target.value)}
859
+ aria-label={`Role for ${label}`}
860
+ className="h-8 rounded-md border border-zinc-300 bg-white px-2 text-[12px] text-zinc-700"
861
+ >
862
+ <option value="member">Member</option>
863
+ <option value="admin">Admin</option>
864
+ </select>
865
+ <button
866
+ type="button"
867
+ onClick={() => void remove(m)}
868
+ className="text-[13px] font-medium text-zinc-400 transition-colors hover:text-red-600"
869
+ >
870
+ Remove
871
+ </button>
872
+ </>
873
+ ) : (
874
+ <RoleBadge role={m.role} />
875
+ )}
745
876
  </li>
746
877
  );
747
878
  })}
@@ -817,17 +948,186 @@ function Count({ n }: { n: number }) {
817
948
 
818
949
  /* ============================ Settings ============================ */
819
950
 
951
+ export interface AccountInfo {
952
+ id: string;
953
+ email: string;
954
+ displayName?: string | null;
955
+ }
956
+
820
957
  export function Settings({
821
958
  org,
822
959
  role,
823
960
  memberCount,
961
+ me,
824
962
  }: {
825
963
  org: OrgInfo | null;
826
964
  role: string;
827
965
  memberCount: number;
966
+ me: AccountInfo | null;
828
967
  }) {
829
968
  if (!org) return <NoOrg />;
830
- return <SettingsView org={org} role={role} memberCount={memberCount} />;
969
+ return (
970
+ <>
971
+ <SettingsView org={org} role={role} memberCount={memberCount} />
972
+ {me ? <AccountSettings me={me} /> : null}
973
+ </>
974
+ );
975
+ }
976
+
977
+ // The signed-in person's own account, below the workspace settings. Display
978
+ // name saves through `updateProfile`; the password and delete-account paths
979
+ // are the framework's own routes.
980
+ function AccountSettings({ me }: { me: AccountInfo }) {
981
+ const [displayName, setDisplayName] = useState(me.displayName ?? "");
982
+ const [savedName, setSavedName] = useState(false);
983
+ const [current, setCurrent] = useState("");
984
+ const [next, setNext] = useState("");
985
+ const [pwNote, setPwNote] = useState<string | null>(null);
986
+ const [busy, setBusy] = useState<string | null>(null);
987
+ const [confirm, setConfirm] = useState("");
988
+ const [deleteError, setDeleteError] = useState<string | null>(null);
989
+
990
+ async function saveName(e: React.FormEvent) {
991
+ e.preventDefault();
992
+ setBusy("name");
993
+ setSavedName(false);
994
+ try {
995
+ await callFn("updateProfile", { displayName });
996
+ setSavedName(true);
997
+ } finally {
998
+ setBusy(null);
999
+ }
1000
+ }
1001
+
1002
+ async function changePassword(e: React.FormEvent) {
1003
+ e.preventDefault();
1004
+ setBusy("password");
1005
+ setPwNote(null);
1006
+ try {
1007
+ const res = await fetch("/api/auth/password/change", {
1008
+ method: "POST",
1009
+ headers: { "content-type": "application/json" },
1010
+ credentials: "include",
1011
+ body: JSON.stringify({ currentPassword: current, newPassword: next }),
1012
+ });
1013
+ const data = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
1014
+ if (!res.ok) throw new Error(data.error?.message ?? "Couldn't change the password.");
1015
+ setCurrent("");
1016
+ setNext("");
1017
+ setPwNote("Password updated.");
1018
+ } catch (err) {
1019
+ setPwNote(err instanceof Error ? err.message : "Couldn't change the password.");
1020
+ } finally {
1021
+ setBusy(null);
1022
+ }
1023
+ }
1024
+
1025
+ async function deleteAccount() {
1026
+ if (confirm.trim().toLowerCase() !== me.email.toLowerCase()) return;
1027
+ setBusy("delete");
1028
+ setDeleteError(null);
1029
+ try {
1030
+ const res = await fetch("/api/auth/account", { method: "DELETE", credentials: "include" });
1031
+ if (!res.ok) {
1032
+ const data = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
1033
+ throw new Error(data.error?.message ?? "Couldn't delete the account.");
1034
+ }
1035
+ window.location.assign("/");
1036
+ } catch (err) {
1037
+ setDeleteError(err instanceof Error ? err.message : "Couldn't delete the account.");
1038
+ setBusy(null);
1039
+ }
1040
+ }
1041
+
1042
+ return (
1043
+ <div className="mt-6 max-w-2xl space-y-6">
1044
+ <Card title="Your account">
1045
+ <form onSubmit={saveName} className="space-y-3">
1046
+ <label className="block">
1047
+ <span className="mb-1.5 block text-[13px] font-medium text-zinc-700">Display name</span>
1048
+ <div className="flex items-center gap-2">
1049
+ <input
1050
+ value={displayName}
1051
+ onChange={(e) => {
1052
+ setDisplayName(e.target.value);
1053
+ setSavedName(false);
1054
+ }}
1055
+ placeholder="How teammates see you"
1056
+ aria-label="Display name"
1057
+ className={inputCls}
1058
+ />
1059
+ <Button type="submit" size="sm" disabled={busy === "name" || !displayName.trim()}>
1060
+ {busy === "name" ? "…" : "Save"}
1061
+ </Button>
1062
+ </div>
1063
+ </label>
1064
+ {savedName && <p className="text-xs text-green-600">Name updated.</p>}
1065
+ <p className="text-[13px] text-zinc-500">
1066
+ Signed in as <span className="font-medium text-zinc-700">{me.email}</span>
1067
+ </p>
1068
+ </form>
1069
+ </Card>
1070
+
1071
+ <Card title="Password">
1072
+ <form onSubmit={changePassword} className="space-y-3">
1073
+ <input
1074
+ type="password"
1075
+ value={current}
1076
+ onChange={(e) => setCurrent(e.target.value)}
1077
+ placeholder="Current password"
1078
+ autoComplete="current-password"
1079
+ aria-label="Current password"
1080
+ className={inputCls}
1081
+ />
1082
+ <input
1083
+ type="password"
1084
+ value={next}
1085
+ onChange={(e) => setNext(e.target.value)}
1086
+ placeholder="New password (10+ characters)"
1087
+ autoComplete="new-password"
1088
+ minLength={10}
1089
+ aria-label="New password"
1090
+ className={inputCls}
1091
+ />
1092
+ {pwNote && <p className="text-xs text-zinc-600">{pwNote}</p>}
1093
+ <Button type="submit" size="sm" variant="outline" disabled={busy === "password" || !current || next.length < 10}>
1094
+ {busy === "password" ? "…" : "Change password"}
1095
+ </Button>
1096
+ <p className="text-[12px] text-zinc-400">
1097
+ Signed in with an email code or Google? Set a password from the{" "}
1098
+ <a href="/forgot-password" className="underline underline-offset-2">reset link</a>.
1099
+ </p>
1100
+ </form>
1101
+ </Card>
1102
+
1103
+ <Card title="Delete account">
1104
+ <p className="text-sm text-zinc-600">
1105
+ Removes your account and signs you out everywhere. Workspaces you own must be deleted
1106
+ or handed to another owner first.
1107
+ </p>
1108
+ <label className="mt-3 block">
1109
+ <span className="mb-1.5 block text-[13px] text-zinc-500">
1110
+ Type <span className="font-medium text-zinc-700">{me.email}</span> to confirm
1111
+ </span>
1112
+ <input
1113
+ value={confirm}
1114
+ onChange={(e) => setConfirm(e.target.value)}
1115
+ aria-label="Confirm email"
1116
+ className={inputCls}
1117
+ />
1118
+ </label>
1119
+ {deleteError && <p className="mt-2 text-xs text-red-600">{deleteError}</p>}
1120
+ <button
1121
+ type="button"
1122
+ onClick={() => void deleteAccount()}
1123
+ disabled={busy === "delete" || confirm.trim().toLowerCase() !== me.email.toLowerCase()}
1124
+ className="mt-3 inline-flex h-9 items-center rounded-lg bg-red-600 px-4 text-[13px] font-medium text-white transition-colors hover:bg-red-700 disabled:opacity-40"
1125
+ >
1126
+ {busy === "delete" ? "Deleting…" : "Delete my account"}
1127
+ </button>
1128
+ </Card>
1129
+ </div>
1130
+ );
831
1131
  }
832
1132
 
833
1133
  function SettingsView({
@@ -1039,8 +1339,24 @@ function BillingView({
1039
1339
  const canManage = isManager(role);
1040
1340
  const [busy, setBusy] = useState<string | null>(null);
1041
1341
  const [error, setError] = useState<string | null>(null);
1342
+ const [annual, setAnnual] = useState(true);
1042
1343
  const active = subscription && ACTIVE.includes(subscription.status);
1043
1344
  const planLabel = active ? subscription!.plan : "free";
1345
+ const pro = planById("pro")!;
1346
+ const savings = annualSavingsPercent(pro);
1347
+
1348
+ // `/dashboard/billing?upgrade=pro&interval=annual` (from /pricing or the
1349
+ // upgrade dialog) starts checkout without another click.
1350
+ useEffect(() => {
1351
+ if (active || !canManage) return;
1352
+ const params = new URLSearchParams(window.location.search);
1353
+ if (params.get("upgrade") !== "pro") return;
1354
+ const wantsAnnual = params.get("interval") !== "monthly";
1355
+ setAnnual(wantsAnnual);
1356
+ window.history.replaceState(null, "", window.location.pathname);
1357
+ void startCheckout(wantsAnnual);
1358
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1359
+ }, []);
1044
1360
 
1045
1361
  function origin() {
1046
1362
  return typeof window !== "undefined" ? window.location.origin : "";
@@ -1069,11 +1385,12 @@ function BillingView({
1069
1385
  }
1070
1386
  }
1071
1387
 
1072
- async function upgrade() {
1388
+ async function startCheckout(yearly: boolean) {
1073
1389
  await run("upgrade", async () => {
1074
1390
  const res = await callFn<{ url: string }>("createCheckoutSession", {
1075
1391
  plan: "pro",
1076
1392
  referenceId: tenantId,
1393
+ annual: yearly,
1077
1394
  successUrl: `${origin()}/dashboard/billing`,
1078
1395
  cancelUrl: `${origin()}/dashboard/billing`,
1079
1396
  });
@@ -1081,6 +1398,10 @@ function BillingView({
1081
1398
  });
1082
1399
  }
1083
1400
 
1401
+ function upgrade() {
1402
+ return startCheckout(annual);
1403
+ }
1404
+
1084
1405
  async function manage() {
1085
1406
  await run("manage", async () => {
1086
1407
  const res = await callFn<{ url: string }>("createBillingPortalSession", {
@@ -1127,7 +1448,13 @@ function BillingView({
1127
1448
  )}
1128
1449
  {!active && (
1129
1450
  <p className="mt-1 text-[13px] text-zinc-500">
1130
- The free plan. Upgrade to Pro for higher limits.
1451
+ The free plan: up to {FREE_PROJECT_LIMIT} active projects. Pro is unlimited and
1452
+ starts with a {TRIAL_DAYS}-day free trial.
1453
+ </p>
1454
+ )}
1455
+ {active && subscription!.status === "trialing" && subscription!.currentPeriodEnd && (
1456
+ <p className="mt-1 text-[13px] text-zinc-500">
1457
+ Trial — first charge on {formatDate(subscription!.currentPeriodEnd)} unless cancelled.
1131
1458
  </p>
1132
1459
  )}
1133
1460
  </div>
@@ -1155,6 +1482,29 @@ function BillingView({
1155
1482
  )}
1156
1483
  </div>
1157
1484
 
1485
+ {!active && canManage && (
1486
+ <div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-zinc-100 pt-3 text-[13px]">
1487
+ <div className="flex items-center gap-2">
1488
+ <button
1489
+ type="button"
1490
+ onClick={() => setAnnual(false)}
1491
+ className={`rounded-md px-2 py-1 ${annual ? "text-zinc-500 hover:text-zinc-900" : "bg-zinc-100 font-medium text-zinc-900"}`}
1492
+ >
1493
+ Monthly {formatPrice(pro.monthly)}/mo
1494
+ </button>
1495
+ <button
1496
+ type="button"
1497
+ onClick={() => setAnnual(true)}
1498
+ className={`rounded-md px-2 py-1 ${annual ? "bg-zinc-100 font-medium text-zinc-900" : "text-zinc-500 hover:text-zinc-900"}`}
1499
+ >
1500
+ Yearly {formatPrice(pro.annualPerMonth ?? pro.monthly)}/mo
1501
+ {savings > 0 ? ` · save ${savings}%` : ""}
1502
+ </button>
1503
+ </div>
1504
+ <span className="text-zinc-400">Card required; no charge for {TRIAL_DAYS} days.</span>
1505
+ </div>
1506
+ )}
1507
+
1158
1508
  {active && canManage && (
1159
1509
  <div className="mt-4 border-t border-zinc-100 pt-3 text-[13px]">
1160
1510
  {subscription!.cancelAtPeriodEnd ? (
@@ -7,6 +7,7 @@ import {
7
7
  type OrgMemberRow,
8
8
  type Subscription,
9
9
  } from "./dashboard-client";
10
+ import type { SetupState } from "@/components/setup-checklist";
10
11
 
11
12
  export const metadata: Metadata = {
12
13
  title: "Dashboard — Acme",
@@ -18,7 +19,7 @@ export const metadata: Metadata = {
18
19
  // render via `serverData` + React 19 `use()` — resolved server-side and
19
20
  // replayed on hydration, so the dashboard paints with real data on the first
20
21
  // byte (no client fetch, no empty-state flash).
21
- export default function DashboardPage({ auth, serverData }: PageProps) {
22
+ export default function DashboardPage({ auth, response, serverData }: PageProps) {
22
23
  // No active workspace (signup's auto-provision failed, or the user left/
23
24
  // deleted their last org). Every read below is tenant-scoped, so instead of
24
25
  // an empty shell, provision one client-side and reload into a ready dashboard.
@@ -27,7 +28,21 @@ export default function DashboardPage({ auth, serverData }: PageProps) {
27
28
  return <ProvisionWorkspace />;
28
29
  }
29
30
  const me = use(serverData.get<{ email?: string }>("User", auth.user_id!));
30
- const org = use(serverData.get<{ name?: string }>("Org", auth.tenant_id));
31
+ const org = use(
32
+ serverData.get<{ name?: string; onboardedAt?: string | null; setupDismissedAt?: string | null }>(
33
+ "Org",
34
+ auth.tenant_id,
35
+ ),
36
+ );
37
+ // A workspace created outside the wizard (or one that abandoned it) gets
38
+ // sent through it once. Owners/admins only; a member who lands here just
39
+ // sees the dashboard.
40
+ const role = auth.roles?.[0] ?? "";
41
+ const manages = role === "owner" || role === "admin";
42
+ if (org && !org.onboardedAt && manages) {
43
+ response.redirect("/onboarding");
44
+ return null;
45
+ }
31
46
  const projects = use(serverData.list<Project>("Project"));
32
47
  const members = use(serverData.list<OrgMemberRow>("OrgMember"));
33
48
  // The OrgMember read policy returns this user's memberships across every org,
@@ -40,8 +55,21 @@ export default function DashboardPage({ auth, serverData }: PageProps) {
40
55
  ["active", "trialing", "past_due"].includes(s.status),
41
56
  );
42
57
  const plan = active ? active.plan : "free";
58
+ // Getting-started checklist, derived from real rows so it ticks itself off.
59
+ const invites = use(serverData.list<{ orgId: string; acceptedAt?: string | null }>("OrgInvite"));
60
+ const pendingInvites = invites.filter((i) => i.orgId === auth.tenant_id && !i.acceptedAt).length;
61
+ const setup: SetupState | null = org?.setupDismissedAt
62
+ ? null
63
+ : {
64
+ orgId: auth.tenant_id,
65
+ hasProject: projects.length > 0,
66
+ hasTeammate: memberCount > 1 || pendingInvites > 0,
67
+ hasBilling: plan !== "free",
68
+ canDismiss: manages,
69
+ };
43
70
  return (
44
71
  <Overview
72
+ setup={setup}
45
73
  tenantId={auth.tenant_id}
46
74
  orgName={org?.name}
47
75
  userEmail={me?.email}
@@ -1,14 +1,13 @@
1
1
  "use client";
2
2
 
3
3
  import React, { useEffect, useState } from "react";
4
- import { createOrg, listOrgs } from "@pylonsync/client";
4
+ import { listOrgs } from "@pylonsync/client";
5
5
  import { db } from "@pylonsync/react";
6
6
 
7
- // Org-less safety net. New signups get "My Workspace" auto-created in the signup
8
- // flow, so this only renders in edge cases: that creation failed, or a user left
9
- // or deleted their last workspace. Pick an existing org if one turned up,
10
- // otherwise create "My Workspace", make it active, and reload into the ready
11
- // dashboard. No multi-step onboarding — there's nothing for the user to decide.
7
+ // Org-less safety net. A new account goes through /onboarding, so this only
8
+ // renders when a user lost their active workspace (left or deleted their last
9
+ // org, or the wizard was abandoned before step 1). Re-select an existing org
10
+ // if one turned up; otherwise send them to the wizard to create one.
12
11
  export function ProvisionWorkspace() {
13
12
  const [error, setError] = useState<string | null>(null);
14
13
 
@@ -17,8 +16,11 @@ export function ProvisionWorkspace() {
17
16
  void (async () => {
18
17
  try {
19
18
  const orgs = await listOrgs();
20
- const target = orgs[0] ?? (await createOrg("My Workspace"));
21
- await db.sync.selectOrg(target.id);
19
+ if (orgs.length === 0) {
20
+ if (!cancelled) window.location.assign("/onboarding");
21
+ return;
22
+ }
23
+ await db.sync.selectOrg(orgs[0].id);
22
24
  if (!cancelled) window.location.reload();
23
25
  } catch (err) {
24
26
  if (!cancelled) {