@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,134 @@
1
+ import React from "react";
2
+ import { Link, type Metadata, type PageProps } from "@pylonsync/react";
3
+ import {
4
+ WRAP,
5
+ Divider,
6
+ Eyebrow,
7
+ FeatureGrid,
8
+ PrimaryButton,
9
+ GhostLink,
10
+ Shot,
11
+ } from "@/components/marketing";
12
+ import { PRODUCTS, productBySlug } from "@/lib/products";
13
+
14
+ // Per-product SEO. `generateMetadata` runs on the server with the resolved
15
+ // route params, so each /products/<slug> page gets its own <title>/<meta> in
16
+ // the HTML — fully indexable, no client work.
17
+ export function generateMetadata({ params }: PageProps): Metadata {
18
+ const product = productBySlug(params.slug);
19
+ if (!product) return { title: "Not found — Acme", robots: "noindex" };
20
+ return {
21
+ title: `${product.title} — Acme`,
22
+ description: product.summary,
23
+ };
24
+ }
25
+
26
+ // `app/products/[slug]/page.tsx` → `/products/:slug`. One template, every
27
+ // product. The slug is resolved from the URL during the server render; an
28
+ // unknown slug becomes a real 404 (via `response.notFound`) before any HTML is
29
+ // sent. Add a product in lib/products.ts and its page exists automatically.
30
+ export default function ProductPage({ params, auth, response }: PageProps) {
31
+ const product = productBySlug(params.slug);
32
+ if (!product) {
33
+ response.notFound();
34
+ return null;
35
+ }
36
+
37
+ const signedIn = Boolean(auth.user_id);
38
+ const primaryHref = signedIn ? "/dashboard" : "/signup";
39
+ const others = PRODUCTS.filter((p) => p.slug !== product.slug);
40
+
41
+ return (
42
+ <div className="bg-white text-zinc-900">
43
+ {/* ============================ HERO ============================ */}
44
+ <section className={`${WRAP} pt-16 pb-16 sm:pt-20`}>
45
+ <Link
46
+ href="/#product"
47
+ className="text-[13px] text-zinc-500 transition-colors hover:text-zinc-900"
48
+ >
49
+ ← All products
50
+ </Link>
51
+ <div className="mt-6 flex items-center gap-3">
52
+ <span className="flex size-9 items-center justify-center rounded-lg bg-brand-soft text-brand">
53
+ {product.icon}
54
+ </span>
55
+ <Eyebrow>{product.eyebrow}</Eyebrow>
56
+ </div>
57
+ <h1 className="mt-5 max-w-2xl text-balance text-[2.5rem] font-semibold leading-[1.05] tracking-[-0.02em] sm:text-[3rem]">
58
+ {product.headline}
59
+ </h1>
60
+ <p className="mt-6 max-w-xl text-[17px] leading-relaxed text-zinc-500">
61
+ {product.summary}
62
+ </p>
63
+ <div className="mt-8 flex flex-wrap items-center gap-4">
64
+ <PrimaryButton href={primaryHref}>
65
+ {signedIn ? "Open dashboard" : "Get started"}
66
+ </PrimaryButton>
67
+ <GhostLink href="/#pricing">See pricing →</GhostLink>
68
+ </div>
69
+
70
+ <div className="mt-16">
71
+ <Shot url={product.mockupUrl} label={product.mockupLabel} />
72
+ </div>
73
+ </section>
74
+
75
+ {/* ========================= FEATURES ========================= */}
76
+ <Divider />
77
+ <section className={`${WRAP} py-20`}>
78
+ <Eyebrow>What you get</Eyebrow>
79
+ <h2 className="mt-4 max-w-xl text-balance text-3xl font-semibold leading-[1.1] tracking-[-0.02em] sm:text-[2.5rem]">
80
+ {product.title}, end to end.
81
+ </h2>
82
+ <FeatureGrid className="mt-12" items={product.features} />
83
+ </section>
84
+
85
+ {/* ==================== EXPLORE OTHER ======================== */}
86
+ <Divider />
87
+ <section className={`${WRAP} py-20`}>
88
+ <Eyebrow>More from Acme</Eyebrow>
89
+ <h2 className="mt-4 text-balance text-3xl font-semibold leading-[1.1] tracking-[-0.02em] sm:text-[2.5rem]">
90
+ Explore the rest of the platform.
91
+ </h2>
92
+ <div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
93
+ {others.map((p) => (
94
+ <Link
95
+ key={p.slug}
96
+ href={`/products/${p.slug}`}
97
+ className="group rounded-2xl border border-zinc-200 bg-paper p-6 transition-colors hover:border-zinc-300 hover:bg-white"
98
+ >
99
+ <span className="flex size-9 items-center justify-center rounded-lg bg-brand-soft text-brand">
100
+ {p.icon}
101
+ </span>
102
+ <h3 className="mt-4 text-[15px] font-semibold">{p.title}</h3>
103
+ <p className="mt-1.5 text-[13px] leading-relaxed text-zinc-500">
104
+ {p.tagline}
105
+ </p>
106
+ <span className="mt-3 inline-block text-[13px] font-medium text-brand">
107
+ Explore →
108
+ </span>
109
+ </Link>
110
+ ))}
111
+ </div>
112
+ </section>
113
+
114
+ {/* =========================== CTA ========================== */}
115
+ <Divider />
116
+ <section className={`${WRAP} py-24`}>
117
+ <h2 className="max-w-xl text-balance text-[2.25rem] font-semibold leading-[1.05] tracking-[-0.02em] sm:text-[2.75rem]">
118
+ Get your team on {product.title}.
119
+ </h2>
120
+ <p className="mt-6 max-w-lg text-[16px] leading-relaxed text-zinc-500">
121
+ Start free and have your first workspace running in under a minute.
122
+ </p>
123
+ <div className="mt-8">
124
+ <PrimaryButton href={primaryHref}>
125
+ {signedIn ? "Open dashboard" : "Start building with Acme"}
126
+ </PrimaryButton>
127
+ </div>
128
+ <p className="mt-4 text-[12px] text-zinc-400">
129
+ Free to start · No credit card · Cancel anytime
130
+ </p>
131
+ </section>
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,28 @@
1
+ import React from "react";
2
+ import { type Metadata, type PageProps } from "@pylonsync/react";
3
+ import { ContentPage } from "@/components/marketing";
4
+ import { RESOURCES, bySlug } from "@/lib/site";
5
+
6
+ export function generateMetadata({ params }: PageProps): Metadata {
7
+ const page = bySlug(RESOURCES, params.slug);
8
+ if (!page) return { title: "Not found — Acme", robots: "noindex" };
9
+ return { title: `${page.navLabel} — Acme`, description: page.summary };
10
+ }
11
+
12
+ // `/resources/:slug` — docs, guides, changelog, API reference, status. Driven
13
+ // by RESOURCES in lib/site.ts. Unknown slugs 404.
14
+ export default function ResourcePage({ params, auth, response }: PageProps) {
15
+ const page = bySlug(RESOURCES, params.slug);
16
+ if (!page) {
17
+ response.notFound();
18
+ return null;
19
+ }
20
+ return (
21
+ <ContentPage
22
+ page={page}
23
+ siblings={RESOURCES}
24
+ basePath="/resources"
25
+ ctaHref={auth.user_id ? "/dashboard" : "/signup"}
26
+ />
27
+ );
28
+ }
@@ -0,0 +1,24 @@
1
+ import React from "react";
2
+ import { type Metadata, type PageProps } from "@pylonsync/react";
3
+ import { AuthShell } from "../auth-shell";
4
+ import { AuthForm } from "../auth-form";
5
+
6
+ export const metadata: Metadata = {
7
+ title: "Create your account — Acme",
8
+ robots: "noindex",
9
+ };
10
+
11
+ // `app/signup/page.tsx` → `/signup`. Split-screen auth, register mode.
12
+ export default function SignupPage({ auth, response }: PageProps) {
13
+ if (auth.user_id) response.redirect("/dashboard");
14
+ return (
15
+ <AuthShell
16
+ title="Create an account"
17
+ switchPrompt="Already have an account?"
18
+ switchLabel="Sign in"
19
+ switchHref="/login"
20
+ >
21
+ <AuthForm mode="signup" />
22
+ </AuthShell>
23
+ );
24
+ }
@@ -0,0 +1,40 @@
1
+ import type { Sitemap } from "@pylonsync/react";
2
+ import { PRODUCTS } from "@/lib/products";
3
+ import { SOLUTIONS, RESOURCES, COMPANY, COMPARISONS } from "@/lib/site";
4
+
5
+ // app/sitemap.ts → served at /sitemap.xml. Enumerates every public, indexable
6
+ // page. The marketing pages are driven by the SAME data modules the nav,
7
+ // footer, and [slug] routes read (lib/products.ts + lib/site.ts), so the
8
+ // sitemap can never list a page that doesn't exist — add a product or a
9
+ // solution and it shows up here automatically. /dashboard is private and
10
+ // /login + /signup are `robots: "noindex"`, so they're intentionally omitted.
11
+ // Point SITE_URL at your domain in production.
12
+ const SITE = process.env.SITE_URL ?? "http://localhost:4321";
13
+
14
+ export default async function sitemap(): Promise<Sitemap> {
15
+ // Each marketing collection → its /base/:slug URLs, typed so the literal
16
+ // changeFrequency isn't widened to string.
17
+ const collection = (base: string, slugs: string[]): Sitemap =>
18
+ slugs.map((slug) => ({
19
+ url: `${SITE}${base}/${slug}`,
20
+ changeFrequency: "monthly",
21
+ priority: 0.7,
22
+ }));
23
+
24
+ return [
25
+ { url: `${SITE}/`, changeFrequency: "weekly", priority: 1 },
26
+ ...collection("/products", PRODUCTS.map((p) => p.slug)),
27
+ ...collection("/solutions", SOLUTIONS.map((s) => s.slug)),
28
+ ...collection("/resources", RESOURCES.map((r) => r.slug)),
29
+ ...collection("/company", COMPANY.map((c) => c.slug)),
30
+ ...collection("/compare", COMPARISONS.map((c) => c.slug)),
31
+ ];
32
+
33
+ // The export is async, so you can also enumerate pages from a DB read:
34
+ //
35
+ // const posts = await fetchPublishedPosts();
36
+ // return [...routes, ...posts.map((p) => ({
37
+ // url: `${SITE}/blog/${p.slug}`,
38
+ // lastModified: p.updatedAt,
39
+ // }))];
40
+ }
@@ -0,0 +1,28 @@
1
+ import React from "react";
2
+ import { type Metadata, type PageProps } from "@pylonsync/react";
3
+ import { ContentPage } from "@/components/marketing";
4
+ import { SOLUTIONS, bySlug } from "@/lib/site";
5
+
6
+ export function generateMetadata({ params }: PageProps): Metadata {
7
+ const page = bySlug(SOLUTIONS, params.slug);
8
+ if (!page) return { title: "Not found — Acme", robots: "noindex" };
9
+ return { title: `${page.navLabel} — Acme`, description: page.summary };
10
+ }
11
+
12
+ // `/solutions/:slug` — one template, every solution. Add an entry to SOLUTIONS
13
+ // in lib/site.ts and its page exists. Unknown slugs 404.
14
+ export default function SolutionPage({ params, auth, response }: PageProps) {
15
+ const page = bySlug(SOLUTIONS, params.slug);
16
+ if (!page) {
17
+ response.notFound();
18
+ return null;
19
+ }
20
+ return (
21
+ <ContentPage
22
+ page={page}
23
+ siblings={SOLUTIONS}
24
+ basePath="/solutions"
25
+ ctaHref={auth.user_id ? "/dashboard" : "/signup"}
26
+ />
27
+ );
28
+ }
@@ -0,0 +1,194 @@
1
+ import {
2
+ entity,
3
+ field,
4
+ policy,
5
+ auth,
6
+ buildManifest,
7
+ discoverAppRoutes,
8
+ } from "@pylonsync/sdk";
9
+ // Per-workspace Stripe billing — see lib/billing.ts. `billing.manifest` brings
10
+ // the StripeSubscription entity + checkout/portal/cancel/restore/webhook actions
11
+ // + their read policy; the matching handlers live in functions/ (one wrapper per
12
+ // handler, re-exported from lib/billing.ts).
13
+ import { billing } from "./lib/billing";
14
+
15
+ // Accounts — email/password is built in (the entity named "User" is the
16
+ // account table; passwordHash is server-only). Each user can belong to many
17
+ // organizations.
18
+ const User = entity(
19
+ "User",
20
+ {
21
+ email: field.string(),
22
+ displayName: field.string().optional(),
23
+ passwordHash: field.string().serverOnly().optional(),
24
+ // The framework's /api/auth/password/register stamps a generated avatar
25
+ // color here, so the User entity must declare it.
26
+ avatarColor: field.string().optional(),
27
+ createdAt: field.datetime().defaultNow(),
28
+ },
29
+ { indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
30
+ );
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Organizations — multi-tenancy is a framework primitive. Declaring these
34
+ // three entities with the names + fields below lights up the built-in
35
+ // `/api/auth/orgs/*` routes (create/list orgs, members, invites) and
36
+ // `/api/auth/select-org` (switch your active tenant). The framework writes
37
+ // only the fields it manages; add your own (logo, plan, billingEmail…) freely.
38
+ // The `@pylonsync/client` `<OrganizationSwitcher>` drives all of this for you.
39
+ // ---------------------------------------------------------------------------
40
+ const Org = entity(
41
+ "Org",
42
+ {
43
+ name: field.string(),
44
+ createdBy: field.id("User"),
45
+ createdAt: field.datetime(),
46
+ // Stripe customer for this workspace's billing (referenceType: "org").
47
+ // The billing plugin creates + stamps this on first checkout; server-only
48
+ // so it never reaches the client.
49
+ stripeCustomerId: field.string().serverOnly().optional(),
50
+ },
51
+ { indexes: [{ name: "by_created_by", fields: ["createdBy"], unique: false }] },
52
+ );
53
+
54
+ // User ↔ Org edge with a role. `select-org` checks this table before letting
55
+ // you switch tenants, so a client can't impersonate an org it doesn't belong
56
+ // to. role ∈ "owner" | "admin" | "member".
57
+ const OrgMember = entity(
58
+ "OrgMember",
59
+ {
60
+ orgId: field.id("Org"),
61
+ userId: field.id("User"),
62
+ role: field.string(),
63
+ joinedAt: field.datetime(),
64
+ },
65
+ {
66
+ indexes: [
67
+ { name: "by_org_user", fields: ["orgId", "userId"], unique: true },
68
+ { name: "by_user", fields: ["userId"], unique: false },
69
+ ],
70
+ },
71
+ );
72
+
73
+ // Pending invite. The framework's /api/auth/orgs/:id/invites endpoints write
74
+ // these (tokenHash is server-only — the raw token only ever goes to the
75
+ // invitee). accepted* are filled in when the invite is redeemed.
76
+ const OrgInvite = entity(
77
+ "OrgInvite",
78
+ {
79
+ orgId: field.id("Org"),
80
+ email: field.string(),
81
+ role: field.string(),
82
+ invitedBy: field.id("User"),
83
+ tokenHash: field.string().serverOnly(),
84
+ tokenPrefix: field.string(),
85
+ createdAt: field.datetime(),
86
+ expiresAt: field.datetime(),
87
+ acceptedAt: field.datetime().optional(),
88
+ acceptedByUserId: field.id("User").optional(),
89
+ },
90
+ {
91
+ indexes: [
92
+ { name: "by_org", fields: ["orgId"], unique: false },
93
+ { name: "by_email_org", fields: ["email", "orgId"], unique: false },
94
+ ],
95
+ },
96
+ );
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Your app's data — one tenant-scoped resource. `orgId` carries the tenant,
100
+ // and the policy scopes every read AND write to your ACTIVE org
101
+ // (`auth.tenantId`, set by select-org). Switch orgs in the UI and the project
102
+ // list changes — clients literally cannot read or write another tenant's rows.
103
+ // ---------------------------------------------------------------------------
104
+ const Project = entity(
105
+ "Project",
106
+ {
107
+ orgId: field.id("Org"),
108
+ name: field.string(),
109
+ createdAt: field.datetime().defaultNow(),
110
+ },
111
+ { indexes: [{ name: "by_org", fields: ["orgId"], unique: false }] },
112
+ );
113
+
114
+ // User rows: read your own; the auth subsystem owns writes.
115
+ const userPolicy = policy({
116
+ name: "user_self",
117
+ entity: "User",
118
+ allowRead: "auth.userId == data.id",
119
+ allowInsert: "false",
120
+ allowUpdate: "false",
121
+ allowDelete: "false",
122
+ });
123
+
124
+ // Org / OrgMember / OrgInvite are managed by the framework's /api/auth/orgs
125
+ // routes (which bypass these policies via the OrgStore). Clients reach them
126
+ // through the `@pylonsync/client` org helpers, not the entity API — so deny
127
+ // direct writes, and scope reads to your own membership / active org.
128
+ const orgPolicy = policy({
129
+ name: "org_access",
130
+ entity: "Org",
131
+ allowRead: "auth.tenantId == data.id",
132
+ allowInsert: "false",
133
+ allowUpdate: "false",
134
+ allowDelete: "false",
135
+ });
136
+ const orgMemberPolicy = policy({
137
+ name: "org_member_access",
138
+ entity: "OrgMember",
139
+ allowRead: "auth.userId == data.userId || auth.tenantId == data.orgId",
140
+ allowInsert: "false",
141
+ allowUpdate: "false",
142
+ allowDelete: "false",
143
+ });
144
+ const orgInvitePolicy = policy({
145
+ name: "org_invite_access",
146
+ entity: "OrgInvite",
147
+ allowRead: "auth.tenantId == data.orgId",
148
+ allowInsert: "false",
149
+ allowUpdate: "false",
150
+ allowDelete: "false",
151
+ });
152
+
153
+ // Projects are scoped to your ACTIVE tenant. `auth.tenantId == data.orgId`
154
+ // gates read AND write — and because orgId is client-supplied at insert time
155
+ // (not stamped later), checking it here means you can only create a project in
156
+ // the org you've selected. Switch orgs → a different project list.
157
+ const projectPolicy = policy({
158
+ name: "project_tenant",
159
+ entity: "Project",
160
+ allowRead: "auth.tenantId == data.orgId",
161
+ allowInsert: "auth.tenantId == data.orgId",
162
+ allowUpdate: "auth.tenantId == data.orgId",
163
+ allowDelete: "auth.tenantId == data.orgId",
164
+ });
165
+
166
+ const manifest = buildManifest({
167
+ name: "__APP_NAME__",
168
+ version: "0.1.0",
169
+ entities: [User, Org, OrgMember, OrgInvite, Project, ...billing.manifest.entities],
170
+ queries: [],
171
+ // Billing actions (createCheckoutSession / createBillingPortalSession /
172
+ // cancelSubscription / restoreSubscription / stripeWebhook). The plugin also
173
+ // declares getSubscription/listSubscriptions queries, but the dashboard reads
174
+ // the StripeSubscription entity directly (it's client-readable via the
175
+ // plugin's policy), so we don't wire those.
176
+ actions: [...billing.manifest.actions],
177
+ policies: [
178
+ userPolicy,
179
+ orgPolicy,
180
+ orgMemberPolicy,
181
+ orgInvitePolicy,
182
+ projectPolicy,
183
+ ...billing.manifest.policies,
184
+ ],
185
+ // Email/password is on by default against the User entity. The org entities
186
+ // above are named with the framework defaults (Org / OrgMember / OrgInvite),
187
+ // so `/api/auth/orgs/*` + `/api/auth/select-org` work with no extra config.
188
+ auth: auth(),
189
+ routes: await discoverAppRoutes(),
190
+ });
191
+
192
+ console.log(JSON.stringify(manifest, null, 2));
193
+
194
+ export default manifest;
@@ -0,0 +1,150 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { Link, useRouter } from "@pylonsync/react";
5
+ import { useAuth, OrganizationSwitcher } from "@pylonsync/client";
6
+ import {
7
+ LayoutDashboard,
8
+ FolderKanban,
9
+ Users,
10
+ CreditCard,
11
+ Settings as SettingsIcon,
12
+ LogOut,
13
+ ExternalLink,
14
+ type LucideIcon,
15
+ } from "lucide-react";
16
+
17
+ type NavKey = "overview" | "projects" | "members" | "billing" | "settings";
18
+
19
+ const NAV: { key: NavKey; label: string; href: string; Icon: LucideIcon }[] = [
20
+ { key: "overview", label: "Overview", href: "/dashboard", Icon: LayoutDashboard },
21
+ { key: "projects", label: "Projects", href: "/dashboard/projects", Icon: FolderKanban },
22
+ { key: "members", label: "Members", href: "/dashboard/members", Icon: Users },
23
+ { key: "billing", label: "Billing", href: "/dashboard/billing", Icon: CreditCard },
24
+ { key: "settings", label: "Settings", href: "/dashboard/settings", Icon: SettingsIcon },
25
+ ];
26
+
27
+ // Dashboard chrome: a fixed sidebar (logo, workspace switcher, nav) plus a top
28
+ // bar with a user menu. The marketing nav/footer are suppressed for /dashboard
29
+ // in the root layout, so this shell is the only chrome here. `userEmail` is
30
+ // resolved on the server (serverData.get("User", …)) and passed in, so the menu
31
+ // shows a real email instead of a raw id.
32
+ export function DashboardShell({
33
+ active,
34
+ title,
35
+ userEmail,
36
+ orgName,
37
+ children,
38
+ }: {
39
+ active: NavKey;
40
+ title: string;
41
+ userEmail: string;
42
+ // Active org name, resolved on the server, so the workspace switcher renders
43
+ // the real name on first paint instead of flashing in after hydration.
44
+ orgName?: string;
45
+ children: React.ReactNode;
46
+ }) {
47
+ const router = useRouter();
48
+ return (
49
+ <div className="flex min-h-screen bg-white text-zinc-900">
50
+ <aside className="hidden w-60 shrink-0 flex-col border-r border-zinc-200 bg-zinc-50/60 md:flex">
51
+ <div className="flex h-14 items-center border-b border-zinc-200 px-4">
52
+ <Link href="/" className="flex items-center gap-2">
53
+ <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
54
+ A
55
+ </span>
56
+ <span className="text-[15px] font-semibold tracking-tight">Acme</span>
57
+ </Link>
58
+ </div>
59
+
60
+ <div className="px-3 py-3">
61
+ {/* Every view's data is resolved server-side for the active tenant,
62
+ so switching orgs re-renders the dashboard for the new workspace.
63
+ A soft client navigation (router.push) re-fetches the SSR page —
64
+ all data updates — without the full-reload white flash. */}
65
+ <OrganizationSwitcher
66
+ initialActiveName={orgName}
67
+ onSwitched={() => router.push("/dashboard")}
68
+ />
69
+ </div>
70
+
71
+ <nav className="flex-1 space-y-0.5 px-3">
72
+ {NAV.map((n) => {
73
+ const isActive = active === n.key;
74
+ return (
75
+ <Link
76
+ key={n.key}
77
+ href={n.href}
78
+ className={
79
+ "flex items-center gap-2.5 rounded-md px-3 py-2 text-[13.5px] font-medium transition-colors " +
80
+ (isActive
81
+ ? "bg-zinc-900 text-white"
82
+ : "text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900")
83
+ }
84
+ >
85
+ <n.Icon
86
+ className={
87
+ "size-[17px] " + (isActive ? "text-white" : "text-zinc-400")
88
+ }
89
+ strokeWidth={2}
90
+ />
91
+ {n.label}
92
+ </Link>
93
+ );
94
+ })}
95
+ </nav>
96
+ </aside>
97
+
98
+ <div className="flex min-w-0 flex-1 flex-col">
99
+ <header className="flex h-14 items-center justify-between border-b border-zinc-200 px-6">
100
+ <h1 className="text-[15px] font-semibold">{title}</h1>
101
+ <UserMenu email={userEmail} />
102
+ </header>
103
+ <main className="flex-1 p-6">
104
+ <div className="mx-auto max-w-4xl">{children}</div>
105
+ </main>
106
+ </div>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ // Avatar + dropdown (email, View site, Sign out). Native <details> so it opens
112
+ // on click with no extra state; the marketing nav/footer aren't here, so this
113
+ // is the only account control.
114
+ function UserMenu({ email }: { email: string }) {
115
+ const { signOut } = useAuth();
116
+ const initial = (email.trim()[0] || "?").toUpperCase();
117
+ async function onSignOut() {
118
+ await signOut();
119
+ window.location.assign("/");
120
+ }
121
+ return (
122
+ <details className="group relative">
123
+ <summary className="flex size-8 cursor-pointer select-none list-none items-center justify-center rounded-full bg-zinc-900 text-[12px] font-semibold text-white marker:hidden [&::-webkit-details-marker]:hidden">
124
+ {initial}
125
+ </summary>
126
+ <div className="absolute right-0 top-full z-40 mt-2 w-56 overflow-hidden rounded-xl border border-zinc-200 bg-white py-1 shadow-[0_16px_48px_-16px_rgba(0,0,0,0.25)]">
127
+ <div className="border-b border-zinc-100 px-3 py-2">
128
+ <div className="truncate text-[13px] font-medium text-zinc-900">
129
+ {email || "Signed in"}
130
+ </div>
131
+ </div>
132
+ <a
133
+ href="/"
134
+ className="flex items-center gap-2 px-3 py-2 text-[13px] text-zinc-700 transition-colors hover:bg-zinc-50"
135
+ >
136
+ <ExternalLink className="size-4 text-zinc-400" strokeWidth={2} />
137
+ View site
138
+ </a>
139
+ <button
140
+ type="button"
141
+ onClick={onSignOut}
142
+ className="flex w-full items-center gap-2 px-3 py-2 text-left text-[13px] text-zinc-700 transition-colors hover:bg-zinc-50"
143
+ >
144
+ <LogOut className="size-4 text-zinc-400" strokeWidth={2} />
145
+ Sign out
146
+ </button>
147
+ </div>
148
+ </details>
149
+ );
150
+ }