@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
@@ -71,9 +71,9 @@ const PYLON_VERSION = JSON.parse(
71
71
  const PLATFORMS_AVAILABLE = ["web", "vite", "ios", "mac", "expo"];
72
72
 
73
73
  const TEMPLATE_REGISTRY = {
74
- ssr: {
74
+ default: {
75
75
  blurb:
76
- "Full-stack SSR — server-rendered React + Link/Image/Tailwind, one server, no Next.js.",
76
+ "SaaS starter — server-rendered marketing landing + multi-tenant dashboard (orgs, members, tenant-scoped data). One app, one port, no Next.js.",
77
77
  // `unified` templates are a single Pylon app (app.ts + app/ routes +
78
78
  // functions/), NOT a monorepo of apps/api + apps/web. `pylon dev`
79
79
  // serves the SSR frontend and the API from one port. They take no
@@ -167,15 +167,14 @@ Usage: npm create @pylonsync/pylon [name] [options]
167
167
  ${tmplLines.join("\n")}
168
168
 
169
169
  --platforms <list> comma list: ${PLATFORMS_AVAILABLE.join(",")} (default: web)
170
- ignored for ssrit's a single full-stack app, no platforms
170
+ ignored for unified templates they're a single full-stack app
171
171
  --bun|--pnpm|--yarn|--npm
172
172
  --skip-install scaffold only, don't run install
173
173
 
174
174
  Examples:
175
- npm create @pylonsync/pylon my-app --template ssr # full-stack SSR, no Next.js
175
+ npm create @pylonsync/pylon my-app # default SaaS landing + multi-tenant dashboard
176
176
  npm create @pylonsync/pylon my-app --template todo # live, optimistic todo (SSR, one port)
177
- npm create @pylonsync/pylon my-app
178
- npm create @pylonsync/pylon my-app --template b2b # multi-tenant SaaS (orgs, members, RBAC)
177
+ npm create @pylonsync/pylon my-app --template b2b # minimal multi-tenant (orgs, members, RBAC)
179
178
  npm create @pylonsync/pylon my-app --template chat # realtime live chat room
180
179
  `);
181
180
  exit(0);
@@ -192,14 +191,17 @@ if (!flags.template) {
192
191
  process.stdout.write(`\n${lines}\n`);
193
192
  const ans = (
194
193
  await rl.question(
195
- `Template (${TEMPLATES_AVAILABLE.join(", ")}) [ssr]: `,
194
+ `Template (${TEMPLATES_AVAILABLE.join(", ")}) [default]: `,
196
195
  )
197
196
  )
198
197
  .trim()
199
198
  .toLowerCase();
200
- flags.template = TEMPLATES_AVAILABLE.includes(ans) ? ans : "ssr";
199
+ flags.template = TEMPLATES_AVAILABLE.includes(ans) ? ans : "default";
201
200
  }
202
- // `unified` templates (ssr) are a single app, not a monorepo they take
201
+ // `ssr` was the original name of the default template; keep it working as a
202
+ // quiet alias so older `--template ssr` invocations don't break.
203
+ if (flags.template === "ssr") flags.template = "default";
204
+ // `unified` templates (default) are a single app, not a monorepo — they take
203
205
  // no platforms. Skip the platform prompt + validation for them entirely.
204
206
  const isUnified = TEMPLATE_REGISTRY[flags.template]?.unified === true;
205
207
  if (!isUnified && !flags.platforms) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/create-pylon",
3
- "version": "0.3.268",
3
+ "version": "0.3.270",
4
4
  "description": "Scaffold a new Pylon app — realtime backend + web/mobile/expo frontends in one command. Run via `npm create @pylonsync/pylon@latest`.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -63,7 +63,7 @@ export default function RootLayout({ children, auth }: LayoutProps) {
63
63
  </header>
64
64
  <main className="mx-auto max-w-3xl px-4 py-10">{children}</main>
65
65
  <footer className="border-t py-6 text-center text-xs text-muted-foreground">
66
- Rendered by Pylon · one server, one port
66
+ Rendered by Pylon
67
67
  </footer>
68
68
  </body>
69
69
  </html>
@@ -16,7 +16,7 @@ import {
16
16
  export const metadata: Metadata = {
17
17
  title: "__APP_NAME__ — full-stack Pylon app",
18
18
  description:
19
- "A server-rendered homepage, email/password auth, and a live client dashboard over one synced backend — one binary, one port.",
19
+ "A server-rendered homepage, email/password auth, and a live client dashboard over one synced backend.",
20
20
  };
21
21
 
22
22
  // `app/page.tsx` → `/`. This page is server-rendered: view source and the copy
@@ -31,7 +31,7 @@ export default function IndexPage({ auth }: PageProps) {
31
31
  <div className="space-y-12">
32
32
  <section className="space-y-5">
33
33
  <span className="inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium text-muted-foreground">
34
- Server-rendered · authenticated · synced · one port
34
+ Server-rendered · authenticated · synced
35
35
  </span>
36
36
  <h1 className="text-4xl font-semibold tracking-tight">
37
37
  Full-stack apps, one binary.
@@ -14,5 +14,5 @@
14
14
  "@/*": ["./*"]
15
15
  }
16
16
  },
17
- "include": ["app.ts", "app/**/*", "components/**/*", "lib/**/*"]
17
+ "include": ["app.ts", "app/**/*", "components/**/*", "lib/**/*", "functions/**/*"]
18
18
  }
@@ -5,7 +5,7 @@ import { ItemList } from "./items-client";
5
5
  export const metadata: Metadata = {
6
6
  title: "__APP_NAME__ — a minimal Pylon app",
7
7
  description:
8
- "One entity, a live list, and an optimistic create — server-rendered over one Pylon backend, one binary, one port.",
8
+ "One entity, a live list, and an optimistic create — server-rendered over one Pylon backend.",
9
9
  };
10
10
 
11
11
  // `app/page.tsx` → `/`. The heading is server-rendered; the list is a client
@@ -14,5 +14,5 @@
14
14
  "@/*": ["./*"]
15
15
  }
16
16
  },
17
- "include": ["app.ts", "app/**/*", "components/**/*", "lib/**/*"]
17
+ "include": ["app.ts", "app/**/*", "components/**/*", "lib/**/*", "functions/**/*"]
18
18
  }
@@ -5,7 +5,7 @@ import { ChatRoom } from "./chat-client";
5
5
  export const metadata: Metadata = {
6
6
  title: "__APP_NAME__ — realtime chat on Pylon",
7
7
  description:
8
- "A live chat room over one Pylon backend — one binary, one port. Open two tabs and watch messages sync instantly.",
8
+ "A live chat room over one Pylon backend. Open two tabs and watch messages sync instantly.",
9
9
  };
10
10
 
11
11
  // `app/page.tsx` → `/`. The header is server-rendered; `<ChatRoom>` is a client
@@ -14,5 +14,5 @@
14
14
  "@/*": ["./*"]
15
15
  }
16
16
  },
17
- "include": ["app.ts", "app/**/*", "components/**/*", "lib/**/*"]
17
+ "include": ["app.ts", "app/**/*", "components/**/*", "lib/**/*", "functions/**/*"]
18
18
  }
@@ -5,7 +5,7 @@ import { Feed } from "./feed-client";
5
5
  export const metadata: Metadata = {
6
6
  title: "__APP_NAME__ — a live social feed on Pylon",
7
7
  description:
8
- "A public feed with optimistic posts and likes, server-rendered over one Pylon backend. One binary, one port. Open two tabs and watch it sync.",
8
+ "A public feed with optimistic posts and likes, server-rendered over one Pylon backend. Open two tabs and watch it sync.",
9
9
  };
10
10
 
11
11
  // `app/page.tsx` → `/`. The intro is server-rendered; `<Feed>` is a client
@@ -14,5 +14,5 @@
14
14
  "@/*": ["./*"]
15
15
  }
16
16
  },
17
- "include": ["app.ts", "app/**/*", "components/**/*", "lib/**/*"]
17
+ "include": ["app.ts", "app/**/*", "components/**/*", "lib/**/*", "functions/**/*"]
18
18
  }
@@ -0,0 +1,19 @@
1
+ # Copy to `.env` and fill in. `pylon dev` loads `.env` automatically.
2
+ # The app runs fine without these — billing just shows a "connect Stripe" state.
3
+
4
+ # ── Stripe billing (per-workspace) ───────────────────────────────────────────
5
+ # The @pylonsync/stripe plugin (see lib/billing.ts) reads these at call time.
6
+ # 1. Create a product + monthly recurring Price in the Stripe dashboard.
7
+ # 2. Put the Price id in STRIPE_PRICE_PRO.
8
+ # 3. Add a webhook endpoint → https://<your-app>/api/fn/stripeWebhook,
9
+ # subscribe to checkout.session.* + customer.subscription.*, and paste its
10
+ # signing secret into STRIPE_WEBHOOK_SECRET.
11
+ STRIPE_SECRET_KEY=sk_test_...
12
+ STRIPE_WEBHOOK_SECRET=whsec_...
13
+ STRIPE_PRICE_PRO=price_...
14
+
15
+ # ── OAuth (optional) ─────────────────────────────────────────────────────────
16
+ # The login/signup pages show a Google button; set these to enable it.
17
+ # PYLON_OAUTH_GOOGLE_CLIENT_ID=...
18
+ # PYLON_OAUTH_GOOGLE_CLIENT_SECRET=...
19
+ # PYLON_OAUTH_GOOGLE_REDIRECT=http://localhost:4321/api/auth/callback/google
@@ -0,0 +1,85 @@
1
+ # __APP_NAME__
2
+
3
+ A full-stack, multi-tenant SaaS starter on [Pylon](https://pylonsync.com),
4
+ branded as a fictional product called **Acme**: a server-rendered marketing
5
+ site (landing page + product / solution / compare / company pages), first-run
6
+ onboarding, email/password + Google auth, organizations with members, roles,
7
+ and invites, tenant-scoped projects, and per-workspace Stripe billing — all
8
+ from one binary on one port. No Next.js, no separate API server, no realtime
9
+ sidecar.
10
+
11
+ ## Develop
12
+
13
+ ```bash
14
+ __RUN_DEV__
15
+ ```
16
+
17
+ Open http://localhost:4321. You get the **Acme landing page**. Sign up, create
18
+ an organization, and you land in a **workspace** with tenant-scoped projects and
19
+ a members panel. Create a second org and switch between them — each org's data
20
+ is private to it. Edit any file under `app/` and save — the page reloads.
21
+
22
+ ## Layout
23
+
24
+ ```
25
+ app.ts User + Org/OrgMember/OrgInvite + Project + Stripe billing manifest
26
+ app/page.tsx "/" — the server-rendered Acme landing page (auth-aware)
27
+ app/layout.tsx marketing nav + footer (rebrand "Acme")
28
+ app/{products,solutions,resources,company,compare}/[slug]/ data-driven marketing pages
29
+ app/login,signup/ email/password + Google (POST /api/auth/password/*)
30
+ app/onboarding/ first-run: create workspace → invite → first project
31
+ app/dashboard/ "/dashboard" — authed; overview, projects, members, billing, settings
32
+ app/dashboard/dashboard-client.tsx the workspace client island
33
+ app/{error,not-found}.tsx hydrated error + 404 boundaries
34
+ app/{robots,sitemap}.ts /robots.txt + /sitemap.xml (enumerates every public page)
35
+ functions/ Stripe checkout/portal/webhook handlers (one file per handler)
36
+ lib/ products.ts + site.ts (marketing content), billing.ts (@pylonsync/stripe)
37
+ app/globals.css Tailwind v4 + shadcn tokens (compiled by Pylon)
38
+ components/ui/ shadcn primitives (Button, Card)
39
+ ```
40
+
41
+ ## How it works
42
+
43
+ **The landing page** (`app/page.tsx`) is server-rendered React — view source and
44
+ the copy + SEO `<head>` are in the HTML, so it's fully indexable. It reads the
45
+ session during the render, so the call-to-action is "Get started" for visitors
46
+ and "Open dashboard" once you're signed in — no flash, no client fetch.
47
+
48
+ **Auth** is built in: `/login` + `/signup` POST to `/api/auth/password/*`, the
49
+ server sets an HttpOnly session cookie, and `/dashboard` redirects anonymous
50
+ visitors with a real 3xx before any HTML (works with JS off).
51
+
52
+ **Multi-tenancy** is a framework primitive. Declaring `Org` / `OrgMember` /
53
+ `OrgInvite` lights up `/api/auth/orgs/*` + `/api/auth/select-org`, driven by
54
+ `<OrganizationSwitcher>` from `@pylonsync/client`. Your data lives in
55
+ tenant-scoped entities (`Project`), gated by policy:
56
+
57
+ ```ts
58
+ allowRead: "auth.tenantId == data.orgId"
59
+ allowInsert: "auth.tenantId == data.orgId"
60
+ ```
61
+
62
+ So `db.useQuery("Project")` returns only your **active org's** projects — switch
63
+ orgs and the list changes, and a client literally cannot read or write another
64
+ tenant's rows. `db.useQuery` is live; `db.insert` is optimistic.
65
+
66
+ ## Make it yours
67
+
68
+ - **Rebrand:** replace "Acme" in `app/page.tsx` + `app/layout.tsx`.
69
+ - **Edit the marketing copy:** the product / solution / compare pages read from
70
+ `lib/products.ts` + `lib/site.ts` — edit one entry and the nav dropdown, the
71
+ footer, and the `[slug]` page all follow (they can't drift).
72
+ - **Add tenant data:** new `entity()` with an `orgId` + the same two policy
73
+ lines — a new tenant-scoped table, typed client and REST/realtime API included.
74
+ - **Add a route:** drop `app/about/page.tsx` and visit `/about`.
75
+ - **Enable billing:** set `STRIPE_SECRET_KEY` + `STRIPE_PRICE_PRO` (see
76
+ `.env.example`); the Billing tab then runs real Stripe Checkout + Customer
77
+ Portal, kept in sync by the `/api/fn/stripeWebhook` handler.
78
+
79
+ ## Deploy
80
+
81
+ ```bash
82
+ pylon deploy
83
+ ```
84
+
85
+ Docs: https://docs.pylonsync.com
@@ -0,0 +1,218 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import { passwordLogin, passwordRegister, ApiError } from "@pylonsync/client";
5
+
6
+ // The email/password form, shared by /login and /signup. It calls the built-in
7
+ // auth API directly — `passwordLogin` / `passwordRegister` (from
8
+ // @pylonsync/client) POST to `/api/auth/password/*`.
9
+ //
10
+ // On success the server sets an HttpOnly session cookie on the response. We do
11
+ // a full navigation to /dashboard rather than a client transition: the fresh
12
+ // page load hands that cookie to the SSR runtime (which resolves auth and
13
+ // renders the dashboard server-side) and to the sync engine (which
14
+ // authenticates with the same cookie via `credentials: include`). Because the
15
+ // cookie is HttpOnly it can never be read by JavaScript, so there is no session
16
+ // token sitting in `localStorage` for an XSS to lift.
17
+ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
18
+ const [email, setEmail] = useState("");
19
+ const [password, setPassword] = useState("");
20
+ const [error, setError] = useState<string | null>(null);
21
+ const [pending, setPending] = useState(false);
22
+
23
+ async function onSubmit(e: React.FormEvent) {
24
+ e.preventDefault();
25
+ setError(null);
26
+ setPending(true);
27
+ try {
28
+ if (mode === "login") {
29
+ await passwordLogin({ email, password });
30
+ // Full navigation: the SSR dashboard re-renders with the new cookie.
31
+ window.location.assign("/dashboard");
32
+ } else {
33
+ await passwordRegister({ email, password });
34
+ // New accounts have no workspace yet — send them through first-run
35
+ // onboarding (which redirects to /dashboard once they're in an org).
36
+ window.location.assign("/onboarding");
37
+ }
38
+ } catch (err) {
39
+ setError(messageFor(err));
40
+ setPending(false); // keep the form up to retry (success navigates away)
41
+ }
42
+ }
43
+
44
+ return (
45
+ <div className="space-y-5">
46
+ <form onSubmit={onSubmit} className="space-y-4">
47
+ <IconField
48
+ label="Email"
49
+ type="email"
50
+ icon={<MailIcon />}
51
+ value={email}
52
+ onChange={setEmail}
53
+ required
54
+ autoComplete="email"
55
+ placeholder="Enter your email"
56
+ />
57
+ <IconField
58
+ label="Password"
59
+ type="password"
60
+ icon={<LockIcon />}
61
+ value={password}
62
+ onChange={setPassword}
63
+ required
64
+ autoComplete={mode === "login" ? "current-password" : "new-password"}
65
+ placeholder="Enter your password"
66
+ />
67
+ {mode === "signup" ? (
68
+ <p className="text-[12px] leading-snug text-zinc-500">
69
+ By joining, you agree to our{" "}
70
+ <a href="/company/privacy" className="underline underline-offset-2">
71
+ Terms
72
+ </a>{" "}
73
+ &amp;{" "}
74
+ <a href="/company/privacy" className="underline underline-offset-2">
75
+ Privacy
76
+ </a>
77
+ . Passwords need 10+ characters.
78
+ </p>
79
+ ) : null}
80
+ {error ? (
81
+ <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[13px] leading-snug text-red-700">
82
+ {error}
83
+ </p>
84
+ ) : null}
85
+ <button
86
+ type="submit"
87
+ disabled={pending}
88
+ className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-zinc-900 text-sm font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-60"
89
+ >
90
+ {pending ? "…" : mode === "login" ? "Sign in" : "Sign up"}
91
+ </button>
92
+ </form>
93
+
94
+ <div className="flex items-center gap-3 text-[11px] uppercase tracking-wide text-zinc-400">
95
+ <span className="h-px flex-1 bg-zinc-200" />
96
+ or continue with
97
+ <span className="h-px flex-1 bg-zinc-200" />
98
+ </div>
99
+
100
+ {/* Social sign-in. The Google provider must be configured (set
101
+ PYLON_OAUTH_GOOGLE_CLIENT_ID / _CLIENT_SECRET / _REDIRECT) — until then
102
+ this button returns a helpful "configure the provider" error. */}
103
+ <a
104
+ href="/api/auth/login/google?callback=/dashboard&redirect=1"
105
+ className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-white text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-50"
106
+ >
107
+ <GoogleIcon />
108
+ Google
109
+ </a>
110
+ </div>
111
+ );
112
+ }
113
+
114
+ function IconField({
115
+ label,
116
+ icon,
117
+ value,
118
+ onChange,
119
+ type = "text",
120
+ required,
121
+ autoComplete,
122
+ placeholder,
123
+ }: {
124
+ label: string;
125
+ icon: React.ReactNode;
126
+ value: string;
127
+ onChange: (v: string) => void;
128
+ type?: string;
129
+ required?: boolean;
130
+ autoComplete?: string;
131
+ placeholder?: string;
132
+ }) {
133
+ return (
134
+ <label className="block">
135
+ <span className="mb-1.5 block text-[13px] font-medium text-zinc-700">
136
+ {label}
137
+ </span>
138
+ <div className="relative">
139
+ <span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400">
140
+ {icon}
141
+ </span>
142
+ <input
143
+ type={type}
144
+ value={value}
145
+ onChange={(e) => onChange(e.target.value)}
146
+ required={required}
147
+ autoComplete={autoComplete}
148
+ placeholder={placeholder}
149
+ className="h-10 w-full rounded-lg border border-zinc-300 bg-white pl-9 pr-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"
150
+ />
151
+ </div>
152
+ </label>
153
+ );
154
+ }
155
+
156
+ function MailIcon() {
157
+ return (
158
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
159
+ <rect x="3" y="5" width="18" height="14" rx="2" />
160
+ <path d="m3 7 9 6 9-6" />
161
+ </svg>
162
+ );
163
+ }
164
+
165
+ function LockIcon() {
166
+ return (
167
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
168
+ <rect x="5" y="11" width="14" height="10" rx="2" />
169
+ <path d="M8 11V7a4 4 0 0 1 8 0v4" />
170
+ </svg>
171
+ );
172
+ }
173
+
174
+ function GoogleIcon() {
175
+ return (
176
+ <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden>
177
+ <path
178
+ fill="#4285F4"
179
+ d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
180
+ />
181
+ <path
182
+ fill="#34A853"
183
+ d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
184
+ />
185
+ <path
186
+ fill="#FBBC05"
187
+ d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
188
+ />
189
+ <path
190
+ fill="#EA4335"
191
+ d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
192
+ />
193
+ </svg>
194
+ );
195
+ }
196
+
197
+ // Map the framework's auth error codes to friendly copy. `ApiError` carries a
198
+ // stable `.code` (and `.status`) so you branch on the code, not the message.
199
+ function messageFor(err: unknown): string {
200
+ if (err instanceof ApiError) {
201
+ switch (err.code) {
202
+ case "INVALID_CREDENTIALS":
203
+ return "Wrong email or password.";
204
+ case "USER_EXISTS":
205
+ return "That email is already in use — sign in instead.";
206
+ case "WEAK_PASSWORD":
207
+ return "Pick a longer password — at least 10 characters.";
208
+ case "PWNED_PASSWORD":
209
+ return "That password has appeared in a known data breach. Choose a different one.";
210
+ case "RATE_LIMITED":
211
+ return "Too many attempts — try again in a minute.";
212
+ default:
213
+ return err.message;
214
+ }
215
+ }
216
+ if (err instanceof Error) return err.message;
217
+ return "Something went wrong. Try again.";
218
+ }
@@ -0,0 +1,76 @@
1
+ import React from "react";
2
+ import { Link } from "@pylonsync/react";
3
+
4
+ // Server-rendered split-screen shell for /login and /signup: the form on the
5
+ // left, a brand/testimonial panel on the right (hidden on small screens). The
6
+ // form itself (the client island) is passed in as `children`.
7
+ export function AuthShell({
8
+ title,
9
+ switchPrompt,
10
+ switchLabel,
11
+ switchHref,
12
+ children,
13
+ }: {
14
+ title: string;
15
+ switchPrompt: string;
16
+ switchLabel: string;
17
+ switchHref: string;
18
+ children: React.ReactNode;
19
+ }) {
20
+ return (
21
+ <div className="grid min-h-screen bg-white lg:grid-cols-2">
22
+ {/* Form side */}
23
+ <div className="flex items-center justify-center px-6 py-12">
24
+ <div className="w-full max-w-[400px] rounded-2xl border border-zinc-200/70 p-8">
25
+ <Link href="/" className="inline-flex">
26
+ <span className="flex size-9 items-center justify-center rounded-xl bg-zinc-900 text-base font-bold text-white">
27
+ A
28
+ </span>
29
+ </Link>
30
+ <h1 className="mt-5 text-[22px] font-semibold tracking-tight text-zinc-900">
31
+ {title}
32
+ </h1>
33
+ <p className="mt-1 text-[13px] text-zinc-500">
34
+ {switchPrompt}{" "}
35
+ <Link
36
+ href={switchHref}
37
+ className="font-medium text-zinc-900 underline underline-offset-2"
38
+ >
39
+ {switchLabel}
40
+ </Link>
41
+ </p>
42
+ <div className="mt-6">{children}</div>
43
+ </div>
44
+ </div>
45
+
46
+ {/* Brand / testimonial side */}
47
+ <div className="relative hidden flex-col justify-center bg-zinc-50 px-14 lg:flex">
48
+ <div className="max-w-md">
49
+ <div className="font-serif text-5xl leading-none text-zinc-300">
50
+ &ldquo;
51
+ </div>
52
+ <blockquote className="mt-2 text-[1.6rem] font-medium leading-snug tracking-tight text-zinc-900">
53
+ Our whole team finally works in one place. Acme makes it easy to
54
+ plan, build, and ship without the busywork.
55
+ </blockquote>
56
+ <div className="mt-8 flex items-center gap-3">
57
+ <span className="flex size-10 items-center justify-center rounded-full bg-zinc-200 text-[13px] font-semibold text-zinc-500">
58
+ MC
59
+ </span>
60
+ <div className="leading-tight">
61
+ <div className="text-sm font-semibold text-zinc-900">
62
+ Maya Chen
63
+ </div>
64
+ <div className="text-[13px] text-zinc-500">
65
+ Head of Product, Northwind
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ <p className="absolute bottom-8 left-14 text-[13px] text-zinc-400">
71
+ Projects, docs, and automation. All in one place.
72
+ </p>
73
+ </div>
74
+ </div>
75
+ );
76
+ }
@@ -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 { COMPANY, bySlug } from "@/lib/site";
5
+
6
+ export function generateMetadata({ params }: PageProps): Metadata {
7
+ const page = bySlug(COMPANY, params.slug);
8
+ if (!page) return { title: "Not found — Acme", robots: "noindex" };
9
+ return { title: `${page.navLabel} — Acme`, description: page.summary };
10
+ }
11
+
12
+ // `/company/:slug` — about, blog, careers, contact, privacy. Driven by COMPANY
13
+ // in lib/site.ts. Unknown slugs 404.
14
+ export default function CompanyPage({ params, auth, response }: PageProps) {
15
+ const page = bySlug(COMPANY, params.slug);
16
+ if (!page) {
17
+ response.notFound();
18
+ return null;
19
+ }
20
+ return (
21
+ <ContentPage
22
+ page={page}
23
+ siblings={COMPANY}
24
+ basePath="/company"
25
+ ctaHref={auth.user_id ? "/dashboard" : "/signup"}
26
+ />
27
+ );
28
+ }
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+ import { type Metadata, type PageProps } from "@pylonsync/react";
3
+ import { ComparePage } from "@/components/marketing";
4
+ import { COMPARISONS, bySlug } from "@/lib/site";
5
+
6
+ export function generateMetadata({ params }: PageProps): Metadata {
7
+ const cmp = bySlug(COMPARISONS, params.slug);
8
+ if (!cmp) return { title: "Not found — Acme", robots: "noindex" };
9
+ return { title: `${cmp.title} — Acme`, description: cmp.summary };
10
+ }
11
+
12
+ // `/compare/:slug` — one template per comparison. Driven by COMPARISONS in
13
+ // lib/site.ts (generic, made-up competitors). Unknown slugs 404.
14
+ export default function CompareSlugPage({ params, auth, response }: PageProps) {
15
+ const cmp = bySlug(COMPARISONS, params.slug);
16
+ if (!cmp) {
17
+ response.notFound();
18
+ return null;
19
+ }
20
+ return (
21
+ <ComparePage
22
+ cmp={cmp}
23
+ all={COMPARISONS}
24
+ ctaHref={auth.user_id ? "/dashboard" : "/signup"}
25
+ />
26
+ );
27
+ }
@@ -0,0 +1,49 @@
1
+ import React, { use } from "react";
2
+ import { type Metadata, type PageProps } from "@pylonsync/react";
3
+ import { DashboardShell } from "@/components/dashboard-shell";
4
+ import { Billing, type Subscription } from "../dashboard-client";
5
+
6
+ export const metadata: Metadata = {
7
+ title: "Billing — Acme",
8
+ robots: "noindex",
9
+ };
10
+
11
+ // `/dashboard/billing` — the active workspace's plan + Stripe checkout/portal.
12
+ // The StripeSubscription row is resolved server-side (the @pylonsync/stripe
13
+ // read policy scopes it to the active tenant), so the plan paints with no flash;
14
+ // upgrade/manage open Stripe and the webhook keeps the row in sync.
15
+ const ACTIVE = ["active", "trialing", "past_due"];
16
+
17
+ export default function BillingPage({ auth, response, serverData }: PageProps) {
18
+ if (!auth.user_id) {
19
+ response.redirect("/login");
20
+ return null;
21
+ }
22
+ const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
23
+ const org = auth.tenant_id
24
+ ? use(serverData.get<{ name?: string }>("Org", auth.tenant_id))
25
+ : null;
26
+ const subs = auth.tenant_id
27
+ ? use(serverData.list<Subscription>("StripeSubscription"))
28
+ : [];
29
+ const subscription =
30
+ subs.find(
31
+ (s) => s.referenceId === auth.tenant_id && ACTIVE.includes(s.status),
32
+ ) ??
33
+ subs[0] ??
34
+ null;
35
+ return (
36
+ <DashboardShell
37
+ active="billing"
38
+ title="Billing"
39
+ userEmail={me?.email ?? ""}
40
+ orgName={org?.name}
41
+ >
42
+ <Billing
43
+ tenantId={auth.tenant_id}
44
+ role={auth.roles?.[0] ?? ""}
45
+ subscription={subscription}
46
+ />
47
+ </DashboardShell>
48
+ );
49
+ }