@pylonsync/create-pylon 0.3.269 → 0.3.271
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 +11 -9
- package/package.json +1 -1
- package/templates/b2b/app/layout.tsx +1 -1
- package/templates/b2b/app/page.tsx +2 -2
- package/templates/b2b/tsconfig.json +1 -1
- package/templates/barebones/app/page.tsx +1 -1
- package/templates/barebones/tsconfig.json +1 -1
- package/templates/chat/app/page.tsx +1 -1
- package/templates/chat/tsconfig.json +1 -1
- package/templates/consumer/app/page.tsx +1 -1
- package/templates/consumer/tsconfig.json +1 -1
- package/templates/default/.env.example +19 -0
- package/templates/{ssr → default}/README.md +20 -6
- package/templates/default/app/auth-form.tsx +218 -0
- package/templates/default/app/auth-shell.tsx +76 -0
- package/templates/default/app/company/[slug]/page.tsx +28 -0
- package/templates/default/app/compare/[slug]/page.tsx +27 -0
- package/templates/default/app/dashboard/billing/page.tsx +49 -0
- package/templates/default/app/dashboard/dashboard-client.tsx +832 -0
- package/templates/default/app/dashboard/members/page.tsx +37 -0
- package/templates/default/app/dashboard/page.tsx +64 -0
- package/templates/default/app/dashboard/projects/page.tsx +37 -0
- package/templates/default/app/dashboard/settings/page.tsx +45 -0
- package/templates/{ssr → default}/app/globals.css +14 -0
- package/templates/default/app/layout.tsx +466 -0
- package/templates/default/app/login/page.tsx +27 -0
- package/templates/default/app/onboarding/onboarding-client.tsx +261 -0
- package/templates/default/app/onboarding/page.tsx +29 -0
- package/templates/default/app/page.tsx +653 -0
- package/templates/default/app/products/[slug]/page.tsx +134 -0
- package/templates/default/app/resources/[slug]/page.tsx +28 -0
- package/templates/default/app/signup/page.tsx +24 -0
- package/templates/default/app/sitemap.ts +40 -0
- package/templates/default/app/solutions/[slug]/page.tsx +28 -0
- package/templates/{ssr → default}/app.ts +17 -2
- package/templates/default/components/dashboard-shell.tsx +150 -0
- package/templates/default/components/marketing.tsx +370 -0
- package/templates/default/functions/_pylonStripeFindActiveSubForReference.ts +3 -0
- package/templates/default/functions/_pylonStripeFindByCustomerId.ts +3 -0
- package/templates/default/functions/_pylonStripeGetCustomerHolder.ts +3 -0
- package/templates/default/functions/_pylonStripeListSubsForReference.ts +3 -0
- package/templates/default/functions/_pylonStripeOrgMembership.ts +3 -0
- package/templates/default/functions/_pylonStripeSetCustomerId.ts +3 -0
- package/templates/default/functions/_pylonStripeUpsertSubscription.ts +3 -0
- package/templates/default/functions/cancelSubscription.ts +3 -0
- package/templates/default/functions/createBillingPortalSession.ts +3 -0
- package/templates/default/functions/createCheckoutSession.ts +3 -0
- package/templates/default/functions/restoreSubscription.ts +3 -0
- package/templates/default/functions/stripeWebhook.ts +3 -0
- package/templates/default/lib/billing.ts +46 -0
- package/templates/default/lib/products.ts +122 -0
- package/templates/default/lib/site.ts +261 -0
- package/templates/{ssr → default}/package.json +2 -0
- package/templates/{ssr → default}/tsconfig.json +2 -2
- package/templates/todo/app/page.tsx +1 -1
- package/templates/todo/tsconfig.json +1 -1
- package/templates/ssr/app/auth-form.tsx +0 -142
- package/templates/ssr/app/dashboard/dashboard-client.tsx +0 -192
- package/templates/ssr/app/dashboard/page.tsx +0 -26
- package/templates/ssr/app/layout.tsx +0 -78
- package/templates/ssr/app/login/page.tsx +0 -47
- package/templates/ssr/app/page.tsx +0 -212
- package/templates/ssr/app/signup/page.tsx +0 -44
- package/templates/ssr/app/sitemap.ts +0 -27
- package/templates/ssr/functions/_keep.ts +0 -13
- /package/templates/{ssr → default}/AGENTS.md +0 -0
- /package/templates/{ssr → default}/app/error.tsx +0 -0
- /package/templates/{ssr → default}/app/not-found.tsx +0 -0
- /package/templates/{ssr → default}/app/robots.ts +0 -0
- /package/templates/{ssr → default}/components/ui/button.tsx +0 -0
- /package/templates/{ssr → default}/components/ui/card.tsx +0 -0
- /package/templates/{ssr → default}/components.json +0 -0
- /package/templates/{ssr → default}/gitignore +0 -0
- /package/templates/{ssr → default}/lib/utils.ts +0 -0
package/bin/create-pylon.js
CHANGED
|
@@ -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
|
-
|
|
74
|
+
default: {
|
|
75
75
|
blurb:
|
|
76
|
-
"
|
|
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
|
|
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
|
|
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(", ")}) [
|
|
194
|
+
`Template (${TEMPLATES_AVAILABLE.join(", ")}) [default]: `,
|
|
196
195
|
)
|
|
197
196
|
)
|
|
198
197
|
.trim()
|
|
199
198
|
.toLowerCase();
|
|
200
|
-
flags.template = TEMPLATES_AVAILABLE.includes(ans) ? ans : "
|
|
199
|
+
flags.template = TEMPLATES_AVAILABLE.includes(ans) ? ans : "default";
|
|
201
200
|
}
|
|
202
|
-
// `
|
|
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.
|
|
3
|
+
"version": "0.3.271",
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
@@ -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
|
|
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
|
|
@@ -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
|
|
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
|
|
@@ -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.
|
|
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
|
|
@@ -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
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
A full-stack, multi-tenant SaaS starter on [Pylon](https://pylonsync.com),
|
|
4
4
|
branded as a fictional product called **Acme**: a server-rendered marketing
|
|
5
|
-
landing page
|
|
6
|
-
|
|
7
|
-
|
|
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.
|
|
8
10
|
|
|
9
11
|
## Develop
|
|
10
12
|
|
|
@@ -20,12 +22,18 @@ is private to it. Edit any file under `app/` and save — the page reloads.
|
|
|
20
22
|
## Layout
|
|
21
23
|
|
|
22
24
|
```
|
|
23
|
-
app.ts User + Org/OrgMember/OrgInvite +
|
|
25
|
+
app.ts User + Org/OrgMember/OrgInvite + Project + Stripe billing manifest
|
|
24
26
|
app/page.tsx "/" — the server-rendered Acme landing page (auth-aware)
|
|
25
27
|
app/layout.tsx marketing nav + footer (rebrand "Acme")
|
|
26
|
-
app/
|
|
27
|
-
app/
|
|
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
|
|
28
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)
|
|
29
37
|
app/globals.css Tailwind v4 + shadcn tokens (compiled by Pylon)
|
|
30
38
|
components/ui/ shadcn primitives (Button, Card)
|
|
31
39
|
```
|
|
@@ -58,9 +66,15 @@ tenant's rows. `db.useQuery` is live; `db.insert` is optimistic.
|
|
|
58
66
|
## Make it yours
|
|
59
67
|
|
|
60
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).
|
|
61
72
|
- **Add tenant data:** new `entity()` with an `orgId` + the same two policy
|
|
62
73
|
lines — a new tenant-scoped table, typed client and REST/realtime API included.
|
|
63
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.
|
|
64
78
|
|
|
65
79
|
## Deploy
|
|
66
80
|
|
|
@@ -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
|
+
&{" "}
|
|
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
|
+
“
|
|
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
|
+
}
|