@pylonsync/create-pylon 0.3.268 → 0.3.270

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 (76) hide show
  1. package/bin/create-pylon.js +11 -9
  2. package/package.json +1 -1
  3. package/templates/b2b/app/layout.tsx +1 -1
  4. package/templates/b2b/app/page.tsx +2 -2
  5. package/templates/b2b/tsconfig.json +1 -1
  6. package/templates/barebones/app/page.tsx +1 -1
  7. package/templates/barebones/tsconfig.json +1 -1
  8. package/templates/chat/app/page.tsx +1 -1
  9. package/templates/chat/tsconfig.json +1 -1
  10. package/templates/consumer/app/page.tsx +1 -1
  11. package/templates/consumer/tsconfig.json +1 -1
  12. package/templates/default/.env.example +19 -0
  13. package/templates/default/README.md +85 -0
  14. package/templates/default/app/auth-form.tsx +218 -0
  15. package/templates/default/app/auth-shell.tsx +76 -0
  16. package/templates/default/app/company/[slug]/page.tsx +28 -0
  17. package/templates/default/app/compare/[slug]/page.tsx +27 -0
  18. package/templates/default/app/dashboard/billing/page.tsx +49 -0
  19. package/templates/default/app/dashboard/dashboard-client.tsx +832 -0
  20. package/templates/default/app/dashboard/members/page.tsx +37 -0
  21. package/templates/default/app/dashboard/page.tsx +64 -0
  22. package/templates/default/app/dashboard/projects/page.tsx +37 -0
  23. package/templates/default/app/dashboard/settings/page.tsx +45 -0
  24. package/templates/{ssr → default}/app/globals.css +14 -0
  25. package/templates/default/app/layout.tsx +466 -0
  26. package/templates/default/app/login/page.tsx +27 -0
  27. package/templates/default/app/onboarding/onboarding-client.tsx +261 -0
  28. package/templates/default/app/onboarding/page.tsx +29 -0
  29. package/templates/default/app/page.tsx +653 -0
  30. package/templates/default/app/products/[slug]/page.tsx +134 -0
  31. package/templates/default/app/resources/[slug]/page.tsx +28 -0
  32. package/templates/default/app/signup/page.tsx +24 -0
  33. package/templates/default/app/sitemap.ts +40 -0
  34. package/templates/default/app/solutions/[slug]/page.tsx +28 -0
  35. package/templates/default/app.ts +194 -0
  36. package/templates/default/components/dashboard-shell.tsx +150 -0
  37. package/templates/default/components/marketing.tsx +370 -0
  38. package/templates/default/functions/_pylonStripeFindActiveSubForReference.ts +3 -0
  39. package/templates/default/functions/_pylonStripeFindByCustomerId.ts +3 -0
  40. package/templates/default/functions/_pylonStripeGetCustomerHolder.ts +3 -0
  41. package/templates/default/functions/_pylonStripeListSubsForReference.ts +3 -0
  42. package/templates/default/functions/_pylonStripeOrgMembership.ts +3 -0
  43. package/templates/default/functions/_pylonStripeSetCustomerId.ts +3 -0
  44. package/templates/default/functions/_pylonStripeUpsertSubscription.ts +3 -0
  45. package/templates/default/functions/cancelSubscription.ts +3 -0
  46. package/templates/default/functions/createBillingPortalSession.ts +3 -0
  47. package/templates/default/functions/createCheckoutSession.ts +3 -0
  48. package/templates/default/functions/restoreSubscription.ts +3 -0
  49. package/templates/default/functions/stripeWebhook.ts +3 -0
  50. package/templates/default/lib/billing.ts +46 -0
  51. package/templates/default/lib/products.ts +122 -0
  52. package/templates/default/lib/site.ts +261 -0
  53. package/templates/{ssr → default}/package.json +2 -0
  54. package/templates/{ssr → default}/tsconfig.json +2 -2
  55. package/templates/todo/app/page.tsx +1 -1
  56. package/templates/todo/tsconfig.json +1 -1
  57. package/templates/ssr/README.md +0 -56
  58. package/templates/ssr/app/auth-form.tsx +0 -142
  59. package/templates/ssr/app/dashboard/dashboard-client.tsx +0 -116
  60. package/templates/ssr/app/dashboard/page.tsx +0 -70
  61. package/templates/ssr/app/layout.tsx +0 -71
  62. package/templates/ssr/app/login/page.tsx +0 -47
  63. package/templates/ssr/app/page.tsx +0 -114
  64. package/templates/ssr/app/signup/page.tsx +0 -44
  65. package/templates/ssr/app/sitemap.ts +0 -27
  66. package/templates/ssr/app.ts +0 -94
  67. package/templates/ssr/functions/_keep.ts +0 -13
  68. /package/templates/{ssr → default}/AGENTS.md +0 -0
  69. /package/templates/{ssr → default}/app/error.tsx +0 -0
  70. /package/templates/{ssr → default}/app/not-found.tsx +0 -0
  71. /package/templates/{ssr → default}/app/robots.ts +0 -0
  72. /package/templates/{ssr → default}/components/ui/button.tsx +0 -0
  73. /package/templates/{ssr → default}/components/ui/card.tsx +0 -0
  74. /package/templates/{ssr → default}/components.json +0 -0
  75. /package/templates/{ssr → default}/gitignore +0 -0
  76. /package/templates/{ssr → default}/lib/utils.ts +0 -0
@@ -0,0 +1,832 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useState } from "react";
4
+ import { db, callFn } from "@pylonsync/react";
5
+ import {
6
+ createInvite,
7
+ deleteOrg,
8
+ listInvites,
9
+ listOrgMembers,
10
+ renameOrg,
11
+ revokeInvite,
12
+ useAuth,
13
+ type OrgMember,
14
+ type PendingInvite,
15
+ } from "@pylonsync/client";
16
+ import { Button } from "@/components/ui/button";
17
+
18
+ export interface Project {
19
+ id: string;
20
+ orgId: string;
21
+ name: string;
22
+ createdAt: string;
23
+ }
24
+
25
+ // OrgMember rows as returned by `serverData.list("OrgMember")` (the entity
26
+ // shape — camelCase fields). The read policy returns the caller's memberships
27
+ // across ALL their orgs plus everyone in the active org, so consumers must
28
+ // filter by `orgId === tenantId` to count just the active workspace.
29
+ export interface OrgMemberRow {
30
+ id: string;
31
+ orgId: string;
32
+ userId: string;
33
+ role: string;
34
+ }
35
+
36
+ // Every view receives its data from the SERVER (resolved via `serverData` +
37
+ // React 19 `use()` in the page) and the active org as `tenantId` (from
38
+ // `auth.tenant_id`). So the first client paint already has the right state —
39
+ // no `useAuth()`/fetch round-trip, no empty-state flash.
40
+
41
+ function NoOrg() {
42
+ return (
43
+ <div className="rounded-xl border border-dashed border-zinc-300 px-6 py-12 text-center">
44
+ <p className="text-sm text-zinc-500">
45
+ You&apos;re not in a workspace yet. Each one is an isolated tenant — its
46
+ projects and members are private to it.
47
+ </p>
48
+ <a
49
+ href="/onboarding"
50
+ className="mt-4 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"
51
+ >
52
+ Set up your workspace
53
+ </a>
54
+ <p className="mt-3 text-xs text-zinc-400">
55
+ …or pick one from the switcher in the sidebar.
56
+ </p>
57
+ </div>
58
+ );
59
+ }
60
+
61
+ function Card({
62
+ title,
63
+ action,
64
+ children,
65
+ }: {
66
+ title: string;
67
+ action?: React.ReactNode;
68
+ children: React.ReactNode;
69
+ }) {
70
+ return (
71
+ <div className="rounded-xl border border-zinc-200 bg-white p-5">
72
+ <div className="flex items-center justify-between">
73
+ <h2 className="text-sm font-semibold text-zinc-900">{title}</h2>
74
+ {action}
75
+ </div>
76
+ <div className="mt-3">{children}</div>
77
+ </div>
78
+ );
79
+ }
80
+
81
+ function Stat({ label, value }: { label: string; value: React.ReactNode }) {
82
+ return (
83
+ <div className="rounded-xl border border-zinc-200 bg-white p-4">
84
+ <div className="text-[11px] font-medium uppercase tracking-wide text-zinc-400">
85
+ {label}
86
+ </div>
87
+ <div className="mt-1 text-2xl font-semibold text-zinc-900">{value}</div>
88
+ </div>
89
+ );
90
+ }
91
+
92
+ const inputCls =
93
+ "h-9 w-full rounded-md 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";
94
+
95
+ /* ============================ Overview ============================ */
96
+
97
+ export function Overview({
98
+ tenantId,
99
+ projects,
100
+ memberCount,
101
+ plan,
102
+ }: {
103
+ tenantId: string | null;
104
+ projects: Project[];
105
+ memberCount: number;
106
+ plan: string;
107
+ }) {
108
+ if (!tenantId) return <NoOrg />;
109
+ return (
110
+ <div className="space-y-6">
111
+ <div className="grid gap-4 sm:grid-cols-3">
112
+ <Stat label="Projects" value={projects.length} />
113
+ <Stat label="Members" value={memberCount} />
114
+ <Stat
115
+ label="Plan"
116
+ value={<span className="capitalize">{plan}</span>}
117
+ />
118
+ </div>
119
+ <Card
120
+ title="Recent projects"
121
+ action={
122
+ <a
123
+ href="/dashboard/projects"
124
+ className="text-[13px] font-medium text-brand hover:underline"
125
+ >
126
+ View all →
127
+ </a>
128
+ }
129
+ >
130
+ {projects.length === 0 ? (
131
+ <p className="text-sm text-zinc-500">
132
+ No projects yet — create one on the Projects tab.
133
+ </p>
134
+ ) : (
135
+ <ul className="divide-y divide-zinc-100">
136
+ {projects.slice(0, 5).map((p) => (
137
+ <li key={p.id} className="py-2.5 text-sm text-zinc-700">
138
+ {p.name}
139
+ </li>
140
+ ))}
141
+ </ul>
142
+ )}
143
+ </Card>
144
+ </div>
145
+ );
146
+ }
147
+
148
+ /* ============================ Projects ============================ */
149
+
150
+ export function Projects({
151
+ tenantId,
152
+ initial,
153
+ }: {
154
+ tenantId: string | null;
155
+ initial: Project[];
156
+ }) {
157
+ if (!tenantId) return <NoOrg />;
158
+ return <ProjectsList orgId={tenantId} initial={initial} />;
159
+ }
160
+
161
+ // Live + optimistic, seeded from the server. `db.useQuery` is reactive
162
+ // (db.insert/db.delete update it instantly across tabs), but until the first
163
+ // server-confirmed sync settles we render the server-passed `initial` rows — so
164
+ // there's no flash of an empty list on load. The policy gates reads on
165
+ // `auth.tenantId == data.orgId`, so this is only ever this org's projects.
166
+ function ProjectsList({
167
+ orgId,
168
+ initial,
169
+ }: {
170
+ orgId: string;
171
+ initial: Project[];
172
+ }) {
173
+ const [name, setName] = useState("");
174
+ const { data, loading } = db.useQuery<Project>("Project");
175
+ const projects = loading ? initial : data.filter((p) => p.orgId === orgId);
176
+
177
+ async function add(e: React.FormEvent) {
178
+ e.preventDefault();
179
+ const value = name.trim();
180
+ if (!value) return;
181
+ setName("");
182
+ await db.insert("Project", { orgId, name: value });
183
+ }
184
+
185
+ return (
186
+ <Card title="Projects">
187
+ <form onSubmit={add} className="flex items-center gap-2">
188
+ <input
189
+ value={name}
190
+ onChange={(e) => setName(e.target.value)}
191
+ placeholder="New project…"
192
+ aria-label="Project name"
193
+ className={inputCls}
194
+ />
195
+ <Button type="submit" size="sm">
196
+ Add
197
+ </Button>
198
+ </form>
199
+ {projects.length === 0 ? (
200
+ <p className="mt-3 text-sm text-zinc-500">No projects yet.</p>
201
+ ) : (
202
+ <ul className="mt-3 space-y-1.5">
203
+ {projects.map((p) => (
204
+ <li
205
+ key={p.id}
206
+ className="flex items-center justify-between rounded-md border border-zinc-200 px-3 py-2 text-sm"
207
+ >
208
+ <span className="truncate">{p.name}</span>
209
+ <button
210
+ type="button"
211
+ aria-label="Delete project"
212
+ onClick={() => db.delete("Project", p.id)}
213
+ className="text-zinc-300 transition-colors hover:text-red-600"
214
+ >
215
+
216
+ </button>
217
+ </li>
218
+ ))}
219
+ </ul>
220
+ )}
221
+ <p className="mt-3 text-xs text-zinc-400">
222
+ Tenant-scoped + live: only this org&apos;s projects (enforced by policy),
223
+ seeded from the server so there&apos;s no load flash.
224
+ </p>
225
+ </Card>
226
+ );
227
+ }
228
+
229
+ /* ============================= Members ============================ */
230
+
231
+ // Active-org info passed from the server (resolved via serverData) so the views
232
+ // render real names instead of raw ids — and instantly, with no fetch flash.
233
+ export interface OrgInfo {
234
+ id: string;
235
+ name: string;
236
+ createdAt: string;
237
+ }
238
+
239
+ const isManager = (role: string) => role === "owner" || role === "admin";
240
+
241
+ function RoleBadge({ role }: { role: string }) {
242
+ const tone =
243
+ role === "owner"
244
+ ? "bg-brand-soft text-brand"
245
+ : role === "admin"
246
+ ? "bg-amber-50 text-amber-700"
247
+ : "bg-zinc-100 text-zinc-600";
248
+ return (
249
+ <span
250
+ className={
251
+ "rounded-full px-2 py-0.5 text-[11px] font-medium capitalize " + tone
252
+ }
253
+ >
254
+ {role}
255
+ </span>
256
+ );
257
+ }
258
+
259
+ export function Members({
260
+ tenantId,
261
+ currentUserId,
262
+ role,
263
+ }: {
264
+ tenantId: string | null;
265
+ currentUserId: string | null;
266
+ role: string;
267
+ }) {
268
+ if (!tenantId) return <NoOrg />;
269
+ return (
270
+ <MembersList orgId={tenantId} currentUserId={currentUserId} role={role} />
271
+ );
272
+ }
273
+
274
+ // The roster comes from the framework's members endpoint, which joins each
275
+ // member's email + name server-side (the User read policy blocks reading other
276
+ // users via sync, so this trusted endpoint is the only place to get identities).
277
+ // Invites + the roster are gated to owners/admins here AND on the server.
278
+ function MembersList({
279
+ orgId,
280
+ currentUserId,
281
+ role,
282
+ }: {
283
+ orgId: string;
284
+ currentUserId: string | null;
285
+ role: string;
286
+ }) {
287
+ const canManage = isManager(role);
288
+ const [members, setMembers] = useState<OrgMember[] | null>(null);
289
+ const [invites, setInvites] = useState<PendingInvite[] | null>(null);
290
+ const [email, setEmail] = useState("");
291
+ const [note, setNote] = useState<string | null>(null);
292
+ const [inviting, setInviting] = useState(false);
293
+
294
+ async function load() {
295
+ setMembers(await listOrgMembers(orgId));
296
+ // Pending invites are admin-gated server-side; only fetch when we'd be
297
+ // allowed to see them (avoids a guaranteed 403 for plain members).
298
+ if (canManage) setInvites(await listInvites(orgId));
299
+ }
300
+ useEffect(() => {
301
+ void load();
302
+ // eslint-disable-next-line react-hooks/exhaustive-deps
303
+ }, [orgId]);
304
+
305
+ async function invite(e: React.FormEvent) {
306
+ e.preventDefault();
307
+ const value = email.trim();
308
+ if (!value) return;
309
+ setInviting(true);
310
+ setNote(null);
311
+ try {
312
+ await createInvite(orgId, value, "member");
313
+ setEmail("");
314
+ setNote(`Invite sent to ${value}.`);
315
+ void load();
316
+ } catch {
317
+ setNote("Couldn't send that invite — check the address and your role.");
318
+ } finally {
319
+ setInviting(false);
320
+ }
321
+ }
322
+
323
+ async function revoke(inviteId: string) {
324
+ setInvites((prev) => prev?.filter((i) => i.id !== inviteId) ?? null);
325
+ try {
326
+ await revokeInvite(orgId, inviteId);
327
+ } finally {
328
+ void load();
329
+ }
330
+ }
331
+
332
+ return (
333
+ <div className="space-y-6">
334
+ <Card title="Members" action={members ? <Count n={members.length} /> : null}>
335
+ {canManage && (
336
+ <form onSubmit={invite} className="flex items-center gap-2">
337
+ <input
338
+ type="email"
339
+ value={email}
340
+ onChange={(e) => setEmail(e.target.value)}
341
+ placeholder="teammate@company.com"
342
+ aria-label="Invite email"
343
+ className={inputCls}
344
+ />
345
+ <Button type="submit" size="sm" disabled={inviting || !email.trim()}>
346
+ {inviting ? "…" : "Invite"}
347
+ </Button>
348
+ </form>
349
+ )}
350
+ {note && <p className="mt-2 text-xs text-zinc-500">{note}</p>}
351
+
352
+ <ul className="mt-3 divide-y divide-zinc-100">
353
+ {members === null
354
+ ? // Skeleton rows while the roster loads — sized to the real row so
355
+ // there's no layout shift when it lands.
356
+ Array.from({ length: 3 }).map((_, i) => (
357
+ <li key={i} className="flex items-center gap-3 py-2.5">
358
+ <div className="size-8 animate-pulse rounded-full bg-zinc-100" />
359
+ <div className="h-3 w-40 animate-pulse rounded bg-zinc-100" />
360
+ </li>
361
+ ))
362
+ : members.map((m) => {
363
+ const label = m.name || m.email || "Unknown member";
364
+ const initial = (label.trim()[0] || "?").toUpperCase();
365
+ const isMe = m.user_id === currentUserId;
366
+ return (
367
+ <li
368
+ key={m.user_id}
369
+ className="flex items-center gap-3 py-2.5"
370
+ >
371
+ <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-zinc-100 text-[12px] font-semibold text-zinc-600">
372
+ {initial}
373
+ </span>
374
+ <div className="min-w-0 flex-1">
375
+ <div className="flex items-center gap-2">
376
+ <span className="truncate text-sm font-medium text-zinc-900">
377
+ {m.name || m.email || "Unknown member"}
378
+ </span>
379
+ {isMe && (
380
+ <span className="rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-medium text-zinc-500">
381
+ You
382
+ </span>
383
+ )}
384
+ </div>
385
+ {m.name && m.email && (
386
+ <div className="truncate text-xs text-zinc-500">
387
+ {m.email}
388
+ </div>
389
+ )}
390
+ </div>
391
+ <RoleBadge role={m.role} />
392
+ </li>
393
+ );
394
+ })}
395
+ </ul>
396
+ {!canManage && (
397
+ <p className="mt-3 text-xs text-zinc-400">
398
+ Only owners and admins can invite new members.
399
+ </p>
400
+ )}
401
+ </Card>
402
+
403
+ {canManage && invites && invites.length > 0 && (
404
+ <Card
405
+ title="Pending invitations"
406
+ action={
407
+ <span className="text-[13px] text-zinc-400">
408
+ {invites.length} pending
409
+ </span>
410
+ }
411
+ >
412
+ <ul className="divide-y divide-zinc-100">
413
+ {invites.map((inv) => (
414
+ <li key={inv.id} className="flex items-center gap-3 py-2.5">
415
+ <span className="flex size-8 shrink-0 items-center justify-center rounded-full border border-dashed border-zinc-300 text-zinc-400">
416
+ <MailIcon />
417
+ </span>
418
+ <div className="min-w-0 flex-1">
419
+ <div className="truncate text-sm font-medium text-zinc-900">
420
+ {inv.email}
421
+ </div>
422
+ <div className="text-xs text-zinc-500">
423
+ Invited · expires {formatDate(unixToIso(inv.expires_at))}
424
+ </div>
425
+ </div>
426
+ <RoleBadge role={inv.role} />
427
+ <button
428
+ type="button"
429
+ onClick={() => revoke(inv.id)}
430
+ className="text-[13px] font-medium text-zinc-400 transition-colors hover:text-red-600"
431
+ >
432
+ Revoke
433
+ </button>
434
+ </li>
435
+ ))}
436
+ </ul>
437
+ </Card>
438
+ )}
439
+ </div>
440
+ );
441
+ }
442
+
443
+ function MailIcon() {
444
+ return (
445
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
446
+ <rect x="3" y="5" width="18" height="14" rx="2" />
447
+ <path d="m3 7 9 6 9-6" />
448
+ </svg>
449
+ );
450
+ }
451
+
452
+ // PendingInvite.expires_at is unix SECONDS; formatDate() wants an ISO string.
453
+ function unixToIso(sec: number) {
454
+ return new Date(sec * 1000).toISOString();
455
+ }
456
+
457
+ function Count({ n }: { n: number }) {
458
+ return (
459
+ <span className="text-[13px] text-zinc-400">
460
+ {n} {n === 1 ? "person" : "people"}
461
+ </span>
462
+ );
463
+ }
464
+
465
+ /* ============================ Settings ============================ */
466
+
467
+ export function Settings({
468
+ org,
469
+ role,
470
+ memberCount,
471
+ }: {
472
+ org: OrgInfo | null;
473
+ role: string;
474
+ memberCount: number;
475
+ }) {
476
+ if (!org) return <NoOrg />;
477
+ return <SettingsView org={org} role={role} memberCount={memberCount} />;
478
+ }
479
+
480
+ function SettingsView({
481
+ org,
482
+ role,
483
+ memberCount,
484
+ }: {
485
+ org: OrgInfo;
486
+ role: string;
487
+ memberCount: number;
488
+ }) {
489
+ const { clearOrg } = useAuth();
490
+ const [name, setName] = useState(org.name);
491
+ const [saving, setSaving] = useState(false);
492
+ const [saved, setSaved] = useState(false);
493
+ const [error, setError] = useState<string | null>(null);
494
+ const canManage = isManager(role);
495
+ const canDelete = role === "owner";
496
+ const dirty = name.trim() !== org.name && name.trim().length > 0;
497
+
498
+ async function rename(e: React.FormEvent) {
499
+ e.preventDefault();
500
+ if (!dirty) return;
501
+ setSaving(true);
502
+ setError(null);
503
+ setSaved(false);
504
+ try {
505
+ await renameOrg(org.id, name.trim());
506
+ setSaved(true);
507
+ // Reflect the new name in the sidebar switcher + everywhere else.
508
+ window.location.reload();
509
+ } catch {
510
+ setError("Couldn't rename — only owners and admins can.");
511
+ setSaving(false);
512
+ }
513
+ }
514
+
515
+ return (
516
+ <div className="max-w-2xl space-y-6">
517
+ <Card title="Workspace">
518
+ <form onSubmit={rename} className="space-y-3">
519
+ <label className="block">
520
+ <span className="mb-1.5 block text-[13px] font-medium text-zinc-700">
521
+ Name
522
+ </span>
523
+ <div className="flex items-center gap-2">
524
+ <input
525
+ value={name}
526
+ onChange={(e) => {
527
+ setName(e.target.value);
528
+ setSaved(false);
529
+ }}
530
+ disabled={!canManage}
531
+ aria-label="Workspace name"
532
+ className={inputCls + " disabled:bg-zinc-50 disabled:text-zinc-500"}
533
+ />
534
+ {canManage && (
535
+ <Button type="submit" size="sm" disabled={!dirty || saving}>
536
+ {saving ? "…" : "Save"}
537
+ </Button>
538
+ )}
539
+ </div>
540
+ </label>
541
+ {saved && (
542
+ <p className="text-xs text-green-600">Workspace name updated.</p>
543
+ )}
544
+ {error && <p className="text-xs text-red-600">{error}</p>}
545
+ </form>
546
+
547
+ <dl className="mt-5 grid grid-cols-2 gap-y-3 border-t border-zinc-100 pt-4 text-sm">
548
+ <dt className="text-zinc-500">Your role</dt>
549
+ <dd className="text-right">
550
+ <RoleBadge role={role} />
551
+ </dd>
552
+ <dt className="text-zinc-500">Members</dt>
553
+ <dd className="text-right text-zinc-900">{memberCount}</dd>
554
+ <dt className="text-zinc-500">Created</dt>
555
+ <dd className="text-right text-zinc-900">{formatDate(org.createdAt)}</dd>
556
+ </dl>
557
+ </Card>
558
+
559
+ <Card title="Danger zone">
560
+ {canDelete ? (
561
+ <DeleteOrg org={org} onDeleted={clearOrg} />
562
+ ) : (
563
+ <p className="text-sm text-zinc-500">
564
+ Only the workspace owner can delete this workspace.
565
+ </p>
566
+ )}
567
+ </Card>
568
+ </div>
569
+ );
570
+ }
571
+
572
+ // Real, irreversible delete: type the workspace name to confirm, then call the
573
+ // framework's owner-gated DELETE endpoint, drop the active org, and bounce back
574
+ // to onboarding.
575
+ function DeleteOrg({
576
+ org,
577
+ onDeleted,
578
+ }: {
579
+ org: OrgInfo;
580
+ onDeleted: () => Promise<void>;
581
+ }) {
582
+ const [confirm, setConfirm] = useState("");
583
+ const [deleting, setDeleting] = useState(false);
584
+ const [error, setError] = useState<string | null>(null);
585
+ const armed = confirm.trim() === org.name;
586
+
587
+ async function remove() {
588
+ if (!armed) return;
589
+ setDeleting(true);
590
+ setError(null);
591
+ try {
592
+ await deleteOrg(org.id);
593
+ await onDeleted();
594
+ window.location.assign("/onboarding");
595
+ } catch {
596
+ setError("Delete failed. Try again.");
597
+ setDeleting(false);
598
+ }
599
+ }
600
+
601
+ return (
602
+ <div className="space-y-3">
603
+ <p className="text-sm text-zinc-600">
604
+ Deleting <span className="font-medium">{org.name}</span> removes its
605
+ projects and members for everyone. This can&apos;t be undone.
606
+ </p>
607
+ <label className="block">
608
+ <span className="mb-1.5 block text-[13px] text-zinc-500">
609
+ Type <span className="font-medium text-zinc-700">{org.name}</span> to
610
+ confirm
611
+ </span>
612
+ <input
613
+ value={confirm}
614
+ onChange={(e) => setConfirm(e.target.value)}
615
+ aria-label="Confirm workspace name"
616
+ className={inputCls}
617
+ />
618
+ </label>
619
+ {error && <p className="text-xs text-red-600">{error}</p>}
620
+ <button
621
+ type="button"
622
+ onClick={remove}
623
+ disabled={!armed || deleting}
624
+ className="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"
625
+ >
626
+ {deleting ? "Deleting…" : "Delete workspace"}
627
+ </button>
628
+ </div>
629
+ );
630
+ }
631
+
632
+ function formatDate(iso: string) {
633
+ const t = Date.parse(iso);
634
+ if (Number.isNaN(t)) return "—";
635
+ return new Date(t).toLocaleDateString(undefined, {
636
+ year: "numeric",
637
+ month: "short",
638
+ day: "numeric",
639
+ });
640
+ }
641
+
642
+ /* ============================ Billing ============================ */
643
+
644
+ // One row of the @pylonsync/stripe StripeSubscription entity (client-readable
645
+ // via the plugin's policy, scoped to the active org by referenceId).
646
+ export interface Subscription {
647
+ id: string;
648
+ referenceId: string;
649
+ plan: string;
650
+ status: string;
651
+ cancelAtPeriodEnd?: boolean;
652
+ currentPeriodEnd?: string;
653
+ }
654
+
655
+ const ACTIVE = ["active", "trialing", "past_due"];
656
+
657
+ export function Billing({
658
+ tenantId,
659
+ role,
660
+ subscription,
661
+ }: {
662
+ tenantId: string | null;
663
+ role: string;
664
+ subscription: Subscription | null;
665
+ }) {
666
+ if (!tenantId) return <NoOrg />;
667
+ return (
668
+ <BillingView tenantId={tenantId} role={role} subscription={subscription} />
669
+ );
670
+ }
671
+
672
+ // Real Stripe billing via the plugin's actions (callFn → /api/fn/*). Upgrade
673
+ // opens Stripe Checkout; Manage opens the Customer Portal; the webhook keeps the
674
+ // StripeSubscription row in sync, so a full page load after returning reflects
675
+ // the new plan. Until STRIPE_SECRET_KEY + STRIPE_PRICE_PRO are set the actions
676
+ // return STRIPE_NOT_CONFIGURED, which we surface as a "connect Stripe" state.
677
+ function BillingView({
678
+ tenantId,
679
+ role,
680
+ subscription,
681
+ }: {
682
+ tenantId: string;
683
+ role: string;
684
+ subscription: Subscription | null;
685
+ }) {
686
+ const canManage = isManager(role);
687
+ const [busy, setBusy] = useState<string | null>(null);
688
+ const [error, setError] = useState<string | null>(null);
689
+ const active = subscription && ACTIVE.includes(subscription.status);
690
+ const planLabel = active ? subscription!.plan : "free";
691
+
692
+ function origin() {
693
+ return typeof window !== "undefined" ? window.location.origin : "";
694
+ }
695
+
696
+ async function run(action: string, fn: () => Promise<void>) {
697
+ setBusy(action);
698
+ setError(null);
699
+ try {
700
+ await fn();
701
+ } catch (e) {
702
+ const msg = e instanceof Error ? e.message : String(e);
703
+ setError(
704
+ /not.?configured|STRIPE_NOT_CONFIGURED/i.test(msg)
705
+ ? "Stripe isn't configured yet — set STRIPE_SECRET_KEY + STRIPE_PRICE_PRO to enable billing."
706
+ : msg,
707
+ );
708
+ setBusy(null);
709
+ }
710
+ }
711
+
712
+ async function upgrade() {
713
+ await run("upgrade", async () => {
714
+ const res = await callFn<{ url: string }>("createCheckoutSession", {
715
+ plan: "pro",
716
+ referenceId: tenantId,
717
+ successUrl: `${origin()}/dashboard/billing`,
718
+ cancelUrl: `${origin()}/dashboard/billing`,
719
+ });
720
+ window.location.assign(res.url);
721
+ });
722
+ }
723
+
724
+ async function manage() {
725
+ await run("manage", async () => {
726
+ const res = await callFn<{ url: string }>("createBillingPortalSession", {
727
+ referenceId: tenantId,
728
+ returnUrl: `${origin()}/dashboard/billing`,
729
+ });
730
+ window.location.assign(res.url);
731
+ });
732
+ }
733
+
734
+ async function cancel() {
735
+ await run("cancel", async () => {
736
+ await callFn("cancelSubscription", {
737
+ referenceId: tenantId,
738
+ scheduleAtPeriodEnd: true,
739
+ });
740
+ window.location.reload();
741
+ });
742
+ }
743
+
744
+ async function restore() {
745
+ await run("restore", async () => {
746
+ await callFn("restoreSubscription", { referenceId: tenantId });
747
+ window.location.reload();
748
+ });
749
+ }
750
+
751
+ return (
752
+ <div className="max-w-2xl space-y-6">
753
+ <Card title="Plan">
754
+ <div className="flex items-center justify-between">
755
+ <div>
756
+ <div className="flex items-center gap-2">
757
+ <span className="text-2xl font-semibold capitalize text-zinc-900">
758
+ {planLabel}
759
+ </span>
760
+ {active && <RoleBadge role={subscription!.status} />}
761
+ </div>
762
+ {active && subscription!.currentPeriodEnd && (
763
+ <p className="mt-1 text-[13px] text-zinc-500">
764
+ {subscription!.cancelAtPeriodEnd ? "Ends" : "Renews"}{" "}
765
+ {formatDate(subscription!.currentPeriodEnd)}
766
+ </p>
767
+ )}
768
+ {!active && (
769
+ <p className="mt-1 text-[13px] text-zinc-500">
770
+ The free plan. Upgrade to Pro for higher limits.
771
+ </p>
772
+ )}
773
+ </div>
774
+
775
+ {canManage ? (
776
+ active ? (
777
+ <Button
778
+ type="button"
779
+ variant="outline"
780
+ size="sm"
781
+ onClick={manage}
782
+ disabled={!!busy}
783
+ >
784
+ {busy === "manage" ? "…" : "Manage billing"}
785
+ </Button>
786
+ ) : (
787
+ <Button type="button" onClick={upgrade} disabled={!!busy}>
788
+ {busy === "upgrade" ? "…" : "Upgrade to Pro"}
789
+ </Button>
790
+ )
791
+ ) : (
792
+ <span className="text-xs text-zinc-400">
793
+ Owners and admins manage billing.
794
+ </span>
795
+ )}
796
+ </div>
797
+
798
+ {active && canManage && (
799
+ <div className="mt-4 border-t border-zinc-100 pt-3 text-[13px]">
800
+ {subscription!.cancelAtPeriodEnd ? (
801
+ <button
802
+ type="button"
803
+ onClick={restore}
804
+ disabled={!!busy}
805
+ className="font-medium text-brand hover:underline disabled:opacity-50"
806
+ >
807
+ {busy === "restore" ? "…" : "Resume subscription"}
808
+ </button>
809
+ ) : (
810
+ <button
811
+ type="button"
812
+ onClick={cancel}
813
+ disabled={!!busy}
814
+ className="text-zinc-500 hover:text-red-600 disabled:opacity-50"
815
+ >
816
+ {busy === "cancel" ? "…" : "Cancel at period end"}
817
+ </button>
818
+ )}
819
+ </div>
820
+ )}
821
+
822
+ {error && <p className="mt-3 text-xs text-red-600">{error}</p>}
823
+ </Card>
824
+
825
+ <p className="text-xs text-zinc-400">
826
+ Powered by Stripe Checkout + Customer Portal via the @pylonsync/stripe
827
+ plugin. Set STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and STRIPE_PRICE_PRO
828
+ to go live; point the webhook at <code>/api/fn/stripeWebhook</code>.
829
+ </p>
830
+ </div>
831
+ );
832
+ }