@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.
- 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
|
@@ -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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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'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've reached the free plan'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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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 {
|
|
4
|
+
import { listOrgs } from "@pylonsync/client";
|
|
5
5
|
import { db } from "@pylonsync/react";
|
|
6
6
|
|
|
7
|
-
// Org-less safety net.
|
|
8
|
-
//
|
|
9
|
-
// or
|
|
10
|
-
//
|
|
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
|
-
|
|
21
|
-
|
|
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) {
|