@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.
Files changed (70) hide show
  1. package/bin/create-pylon.js +10 -2
  2. package/package.json +1 -1
  3. package/templates/backend/mobile/apps/api/.env.example +22 -0
  4. package/templates/backend/mobile/apps/api/README.md +31 -0
  5. package/templates/backend/mobile/apps/api/app.ts +89 -0
  6. package/templates/backend/mobile/apps/api/functions/_pylonRcUpsertEntitlement.ts +1 -0
  7. package/templates/backend/mobile/apps/api/functions/createNote.ts +44 -0
  8. package/templates/backend/mobile/apps/api/functions/revenuecatWebhook.ts +1 -0
  9. package/templates/backend/mobile/apps/api/functions/syncEntitlements.ts +1 -0
  10. package/templates/backend/mobile/apps/api/lib/purchases.ts +22 -0
  11. package/templates/backend/mobile/apps/api/package.json +23 -0
  12. package/templates/backend/mobile/apps/api/tsconfig.json +13 -0
  13. package/templates/expo/mobile/apps/expo/.env.example +19 -0
  14. package/templates/expo/mobile/apps/expo/README.md +34 -0
  15. package/templates/expo/mobile/apps/expo/STORE.md +82 -0
  16. package/templates/expo/mobile/apps/expo/app/(auth)/_layout.tsx +6 -0
  17. package/templates/expo/mobile/apps/expo/app/(auth)/sign-in.tsx +194 -0
  18. package/templates/expo/mobile/apps/expo/app/(auth)/verify.tsx +78 -0
  19. package/templates/expo/mobile/apps/expo/app/(onboarding)/_layout.tsx +6 -0
  20. package/templates/expo/mobile/apps/expo/app/(onboarding)/welcome.tsx +125 -0
  21. package/templates/expo/mobile/apps/expo/app/(tabs)/_layout.tsx +27 -0
  22. package/templates/expo/mobile/apps/expo/app/(tabs)/index.tsx +128 -0
  23. package/templates/expo/mobile/apps/expo/app/(tabs)/settings.tsx +122 -0
  24. package/templates/expo/mobile/apps/expo/app/_layout.tsx +61 -0
  25. package/templates/expo/mobile/apps/expo/app/index.tsx +15 -0
  26. package/templates/expo/mobile/apps/expo/app/paywall.tsx +177 -0
  27. package/templates/expo/mobile/apps/expo/app.config.ts +70 -0
  28. package/templates/expo/mobile/apps/expo/assets/adaptive-icon.png +0 -0
  29. package/templates/expo/mobile/apps/expo/assets/icon.png +0 -0
  30. package/templates/expo/mobile/apps/expo/assets/splash-icon.png +0 -0
  31. package/templates/expo/mobile/apps/expo/babel.config.js +8 -0
  32. package/templates/expo/mobile/apps/expo/eas.json +45 -0
  33. package/templates/expo/mobile/apps/expo/package.json +52 -0
  34. package/templates/expo/mobile/apps/expo/src/analytics.ts +25 -0
  35. package/templates/expo/mobile/apps/expo/src/entitlements.ts +21 -0
  36. package/templates/expo/mobile/apps/expo/src/flags.ts +31 -0
  37. package/templates/expo/mobile/apps/expo/src/purchases.ts +164 -0
  38. package/templates/expo/mobile/apps/expo/src/pylon.ts +23 -0
  39. package/templates/expo/mobile/apps/expo/src/session.tsx +101 -0
  40. package/templates/expo/mobile/apps/expo/src/theme.ts +36 -0
  41. package/templates/expo/mobile/apps/expo/src/ui.tsx +187 -0
  42. package/templates/expo/mobile/apps/expo/tsconfig.json +10 -0
  43. package/templates/saas/.env.example +2 -0
  44. package/templates/saas/README.md +12 -8
  45. package/templates/saas/app/(auth)/auth-form.tsx +136 -95
  46. package/templates/saas/app/(auth)/forgot-password/page.tsx +20 -0
  47. package/templates/saas/app/(auth)/recovery-forms.tsx +135 -0
  48. package/templates/saas/app/(auth)/reset-password/page.tsx +24 -0
  49. package/templates/saas/app/(marketing)/layout.tsx +1 -1
  50. package/templates/saas/app/(marketing)/page.tsx +3 -52
  51. package/templates/saas/app/(marketing)/pricing/page.tsx +43 -0
  52. package/templates/saas/app/dashboard/dashboard-client.tsx +368 -18
  53. package/templates/saas/app/dashboard/page.tsx +30 -2
  54. package/templates/saas/app/dashboard/provision-workspace.tsx +10 -8
  55. package/templates/saas/app/dashboard/settings/page.tsx +3 -0
  56. package/templates/saas/app/onboarding/onboarding-client.tsx +250 -0
  57. package/templates/saas/app/onboarding/page.tsx +34 -0
  58. package/templates/saas/app/sitemap.ts +1 -0
  59. package/templates/saas/app.ts +18 -10
  60. package/templates/saas/components/pricing-table.tsx +97 -0
  61. package/templates/saas/components/setup-checklist.tsx +83 -0
  62. package/templates/saas/functions/completeOnboarding.ts +15 -0
  63. package/templates/saas/functions/createProject.ts +56 -0
  64. package/templates/saas/functions/dismissSetup.ts +14 -0
  65. package/templates/saas/functions/updateProfile.ts +18 -0
  66. package/templates/saas/lib/billing.ts +10 -3
  67. package/templates/saas/lib/plans.ts +94 -0
  68. package/templates/saas/lib/site.config.ts +13 -45
  69. package/templates/saas/tests/plans.test.ts +35 -0
  70. package/templates/saas/app/not-found.tsx +0 -44
@@ -1,38 +1,33 @@
1
1
  "use client";
2
2
 
3
3
  import React, { useEffect, useState } from "react";
4
- import { db } from "@pylonsync/react";
4
+ import { Link } from "@pylonsync/react";
5
5
  import {
6
6
  passwordLogin,
7
7
  passwordRegister,
8
8
  persistSession,
9
- createOrg,
10
9
  ApiError,
11
10
  } from "@pylonsync/client";
12
11
 
13
- // The email/password form, shared by /login and /signup. It calls the built-in
14
- // auth API directly — `passwordLogin` / `passwordRegister` (from
15
- // @pylonsync/client) POST to `/api/auth/password/*`.
12
+ // The email/password form shared by /login and /signup, plus an email-code
13
+ // path ("Email me a code") for people who never set a password. Every path
14
+ // calls the built-in auth API (`/api/auth/password/*`, `/api/auth/magic/*`).
16
15
  //
17
- // On success the server sets a session cookie (used by the SSR runtime to
18
- // resolve auth on the next full navigation) AND returns the session token. We
19
- // call `persistSession` with that token so the sync engine adopts the new
20
- // identity: it writes the token to storage (overwriting any earlier guest
21
- // token a demo flow may have left behind) and re-fetches `/api/auth/me`.
22
- //
23
- // Skipping this was a real footgun: a stale anonymous guest token left in
24
- // localStorage would be sent as a `Bearer` on the engine's auth calls, where
25
- // the server prefers it over the cookie — so `selectOrg` would 401 with
26
- // "anonymous session" even though the cookie was a valid user.
16
+ // On success the server sets the session cookie AND returns the token; we
17
+ // call `persistSession` so the sync engine adopts the identity at once (a
18
+ // stale guest token in localStorage would otherwise shadow the cookie on the
19
+ // engine's calls). Sign-up then lands on /onboarding, which creates the
20
+ // workspace; sign-in lands on /dashboard.
27
21
  export function AuthForm({ mode }: { mode: "login" | "signup" }) {
28
22
  const [email, setEmail] = useState("");
29
23
  const [password, setPassword] = useState("");
24
+ const [code, setCode] = useState("");
25
+ const [codeSent, setCodeSent] = useState(false);
26
+ const [method, setMethod] = useState<"password" | "code">("password");
30
27
  const [error, setError] = useState<string | null>(null);
31
28
  const [pending, setPending] = useState(false);
32
- // OAuth buttons render only for providers the server actually has
33
- // configured (GET /api/auth/providers) — an unconfigured provider's
34
- // login URL is a JSON error dead-end, not a login page. `null` while
35
- // loading so nothing flashes in.
29
+ // OAuth buttons render only for providers the server has configured
30
+ // (GET /api/auth/providers). `null` while loading so nothing flashes in.
36
31
  const [providers, setProviders] = useState<string[] | null>(null);
37
32
 
38
33
  useEffect(() => {
@@ -50,37 +45,39 @@ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
50
45
  };
51
46
  }, []);
52
47
 
48
+ const destination = mode === "login" ? "/dashboard" : "/onboarding";
49
+
53
50
  async function onSubmit(e: React.FormEvent) {
54
51
  e.preventDefault();
55
52
  setError(null);
56
53
  setPending(true);
57
54
  try {
58
- if (mode === "login") {
59
- const session = await passwordLogin({ email, password });
60
- // Adopt the real identity locally before navigating — otherwise a
61
- // leftover guest token shadows this session on the sync engine's calls.
62
- persistSession(session);
63
- // Full navigation: the SSR dashboard re-renders with the new cookie.
64
- window.location.assign("/dashboard");
65
- } else {
66
- const session = await passwordRegister({ email, password });
67
- persistSession(session);
68
- // Auto-provision a first workspace so new accounts land in a ready
69
- // dashboard instead of an empty first-run screen. Named "My Workspace"
70
- // (renamable in Settings) and made the active tenant. Either way we land
71
- // on /dashboard — if provisioning failed here, the dashboard's org-less
72
- // safety net retries it.
73
- try {
74
- const org = await createOrg("My Workspace");
75
- await db.sync.selectOrg(org.id);
76
- } catch {
77
- // Swallowed — the dashboard provisions on load if there's still no org.
55
+ if (method === "code") {
56
+ if (!codeSent) {
57
+ await postJson("/api/auth/magic/send", { email });
58
+ setCodeSent(true);
59
+ setPending(false);
60
+ return;
78
61
  }
79
- window.location.assign("/dashboard");
62
+ const session = await postJson<{ token: string; user_id?: string | null }>(
63
+ "/api/auth/magic/verify",
64
+ { email, code },
65
+ );
66
+ persistSession({ token: session.token, user_id: session.user_id ?? "" });
67
+ // A code sign-in may have just created the account; the wizard
68
+ // sends an already-onboarded workspace straight to the dashboard.
69
+ window.location.assign("/onboarding");
70
+ return;
80
71
  }
72
+ const session =
73
+ mode === "login"
74
+ ? await passwordLogin({ email, password })
75
+ : await passwordRegister({ email, password });
76
+ persistSession(session);
77
+ window.location.assign(destination);
81
78
  } catch (err) {
82
79
  setError(messageFor(err));
83
- setPending(false); // keep the form up to retry (success navigates away)
80
+ setPending(false);
84
81
  }
85
82
  }
86
83
 
@@ -96,18 +93,43 @@ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
96
93
  required
97
94
  autoComplete="email"
98
95
  placeholder="Enter your email"
96
+ disabled={codeSent}
99
97
  />
100
- <IconField
101
- label="Password"
102
- type="password"
103
- icon={<LockIcon />}
104
- value={password}
105
- onChange={setPassword}
106
- required
107
- autoComplete={mode === "login" ? "current-password" : "new-password"}
108
- placeholder="Enter your password"
109
- />
110
- {mode === "signup" ? (
98
+ {method === "password" ? (
99
+ <IconField
100
+ label="Password"
101
+ type="password"
102
+ icon={<LockIcon />}
103
+ value={password}
104
+ onChange={setPassword}
105
+ required
106
+ autoComplete={mode === "login" ? "current-password" : "new-password"}
107
+ placeholder={mode === "login" ? "Enter your password" : "Choose a password (10+ characters)"}
108
+ trailing={
109
+ mode === "login" ? (
110
+ <Link
111
+ href="/forgot-password"
112
+ className="text-[12px] text-zinc-500 underline underline-offset-2 hover:text-zinc-900"
113
+ >
114
+ Forgot password?
115
+ </Link>
116
+ ) : null
117
+ }
118
+ />
119
+ ) : codeSent ? (
120
+ <IconField
121
+ label="6-digit code"
122
+ type="text"
123
+ icon={<LockIcon />}
124
+ value={code}
125
+ onChange={(v) => setCode(v.replace(/\D/g, "").slice(0, 6))}
126
+ required
127
+ autoComplete="one-time-code"
128
+ placeholder="123456"
129
+ hint={`We emailed a code to ${email}.`}
130
+ />
131
+ ) : null}
132
+ {mode === "signup" && method === "password" ? (
111
133
  <p className="text-[12px] leading-snug text-zinc-500">
112
134
  By joining, you agree to our{" "}
113
135
  <a href="/company/terms" className="underline underline-offset-2">
@@ -117,7 +139,7 @@ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
117
139
  <a href="/company/privacy" className="underline underline-offset-2">
118
140
  Privacy
119
141
  </a>
120
- . Passwords need 10+ characters.
142
+ .
121
143
  </p>
122
144
  ) : null}
123
145
  {error ? (
@@ -127,13 +149,34 @@ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
127
149
  ) : null}
128
150
  <button
129
151
  type="submit"
130
- disabled={pending}
152
+ disabled={pending || (method === "code" && codeSent && code.length !== 6)}
131
153
  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"
132
154
  >
133
- {pending ? "…" : mode === "login" ? "Sign in" : "Sign up"}
155
+ {pending
156
+ ? "…"
157
+ : method === "code"
158
+ ? codeSent
159
+ ? "Continue"
160
+ : "Email me a code"
161
+ : mode === "login"
162
+ ? "Sign in"
163
+ : "Create account"}
134
164
  </button>
135
165
  </form>
136
166
 
167
+ <button
168
+ type="button"
169
+ onClick={() => {
170
+ setMethod(method === "password" ? "code" : "password");
171
+ setCodeSent(false);
172
+ setCode("");
173
+ setError(null);
174
+ }}
175
+ className="w-full text-center text-[13px] text-zinc-500 underline underline-offset-2 hover:text-zinc-900"
176
+ >
177
+ {method === "password" ? "Email me a sign-in code instead" : "Use a password instead"}
178
+ </button>
179
+
137
180
  {/* Social sign-in — only for providers the server reports as
138
181
  configured. Enable Google with PYLON_OAUTH_GOOGLE_CLIENT_ID /
139
182
  _CLIENT_SECRET / _REDIRECT (GitHub: PYLON_OAUTH_GITHUB_*) and the
@@ -148,7 +191,7 @@ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
148
191
 
149
192
  {providers.includes("google") ? (
150
193
  <a
151
- href="/api/auth/login/google?callback=/dashboard&redirect=1"
194
+ href={`/api/auth/login/google?callback=${destination}&redirect=1`}
152
195
  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"
153
196
  >
154
197
  <GoogleIcon />
@@ -157,7 +200,7 @@ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
157
200
  ) : null}
158
201
  {providers.includes("github") ? (
159
202
  <a
160
- href="/api/auth/login/github?callback=/dashboard&redirect=1"
203
+ href={`/api/auth/login/github?callback=${destination}&redirect=1`}
161
204
  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"
162
205
  >
163
206
  <GitHubIcon />
@@ -170,44 +213,56 @@ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
170
213
  );
171
214
  }
172
215
 
216
+ async function postJson<T = Record<string, unknown>>(path: string, body: unknown): Promise<T> {
217
+ const res = await fetch(path, {
218
+ method: "POST",
219
+ headers: { "content-type": "application/json" },
220
+ credentials: "include",
221
+ body: JSON.stringify(body),
222
+ });
223
+ const data = (await res.json().catch(() => ({}))) as T & {
224
+ error?: { code?: string; message?: string };
225
+ };
226
+ if (!res.ok) {
227
+ throw new Error(data.error?.message ?? `Request failed (${res.status})`);
228
+ }
229
+ return data;
230
+ }
231
+
173
232
  function IconField({
174
233
  label,
175
234
  icon,
176
235
  value,
177
236
  onChange,
178
- type = "text",
179
- required,
180
- autoComplete,
181
- placeholder,
237
+ trailing,
238
+ hint,
239
+ ...rest
182
240
  }: {
183
241
  label: string;
184
242
  icon: React.ReactNode;
185
243
  value: string;
186
244
  onChange: (v: string) => void;
187
- type?: string;
188
- required?: boolean;
189
- autoComplete?: string;
190
- placeholder?: string;
191
- }) {
245
+ trailing?: React.ReactNode;
246
+ hint?: string;
247
+ } & Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange">) {
192
248
  return (
193
249
  <label className="block">
194
- <span className="mb-1.5 block text-[13px] font-medium text-zinc-700">
250
+ <span className="mb-1.5 flex items-center justify-between text-[13px] font-medium text-zinc-700">
195
251
  {label}
252
+ {trailing}
196
253
  </span>
197
- <div className="relative">
198
- <span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400">
254
+ <span className="relative block">
255
+ <span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-zinc-400">
199
256
  {icon}
200
257
  </span>
201
258
  <input
202
- type={type}
203
259
  value={value}
204
260
  onChange={(e) => onChange(e.target.value)}
205
- required={required}
206
- autoComplete={autoComplete}
207
- placeholder={placeholder}
208
- 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"
261
+ 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 disabled:bg-zinc-50 disabled:text-zinc-500"
262
+ {...rest}
209
263
  />
210
- </div>
264
+ </span>
265
+ {hint ? <span className="mt-1.5 block text-[12px] text-zinc-500">{hint}</span> : null}
211
266
  </label>
212
267
  );
213
268
  }
@@ -224,7 +279,7 @@ function MailIcon() {
224
279
  function LockIcon() {
225
280
  return (
226
281
  <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
227
- <rect x="5" y="11" width="14" height="10" rx="2" />
282
+ <rect x="4" y="11" width="16" height="10" rx="2" />
228
283
  <path d="M8 11V7a4 4 0 0 1 8 0v4" />
229
284
  </svg>
230
285
  );
@@ -233,7 +288,7 @@ function LockIcon() {
233
288
  function GitHubIcon() {
234
289
  return (
235
290
  <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
236
- <path d="M12 1C5.92 1 1 5.92 1 12c0 4.87 3.15 9 7.52 10.45.55.1.75-.24.75-.53 0-.26-.01-.96-.01-1.88-3.06.66-3.71-1.48-3.71-1.48-.5-1.27-1.22-1.61-1.22-1.61-1-.68.08-.67.08-.67 1.1.08 1.68 1.13 1.68 1.13.98 1.68 2.57 1.19 3.2.91.1-.71.38-1.19.69-1.47-2.44-.28-5.01-1.22-5.01-5.44 0-1.2.43-2.18 1.13-2.95-.11-.28-.49-1.4.11-2.91 0 0 .92-.3 3.03 1.13a10.5 10.5 0 0 1 5.52 0c2.1-1.43 3.03-1.13 3.03-1.13.6 1.51.22 2.63.11 2.91.7.77 1.13 1.75 1.13 2.95 0 4.23-2.58 5.16-5.03 5.43.39.34.74 1 .74 2.02 0 1.47-.01 2.65-.01 3.01 0 .29.2.64.76.53A11.01 11.01 0 0 0 23 12c0-6.08-4.92-11-11-11z" />
291
+ <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
237
292
  </svg>
238
293
  );
239
294
  }
@@ -241,28 +296,14 @@ function GitHubIcon() {
241
296
  function GoogleIcon() {
242
297
  return (
243
298
  <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden>
244
- <path
245
- fill="#4285F4"
246
- 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"
247
- />
248
- <path
249
- fill="#34A853"
250
- 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"
251
- />
252
- <path
253
- fill="#FBBC05"
254
- 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"
255
- />
256
- <path
257
- fill="#EA4335"
258
- 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"
259
- />
299
+ <path fill="#4285F4" d="M23.49 12.27c0-.79-.07-1.54-.19-2.27H12v4.51h6.47a5.53 5.53 0 0 1-2.4 3.63v3.01h3.87c2.27-2.09 3.55-5.17 3.55-8.88z" />
300
+ <path fill="#34A853" d="M12 24c3.24 0 5.95-1.08 7.94-2.91l-3.87-3.01c-1.08.72-2.45 1.16-4.07 1.16-3.13 0-5.78-2.11-6.73-4.96H1.29v3.09A12 12 0 0 0 12 24z" />
301
+ <path fill="#FBBC05" d="M5.27 14.28A7.2 7.2 0 0 1 4.89 12c0-.79.14-1.56.38-2.28V6.63H1.29A12 12 0 0 0 0 12c0 1.94.46 3.77 1.29 5.37l3.98-3.09z" />
302
+ <path fill="#EA4335" d="M12 4.75c1.77 0 3.35.61 4.6 1.8l3.44-3.44C17.95 1.19 15.24 0 12 0A12 12 0 0 0 1.29 6.63l3.98 3.09C6.22 6.86 8.87 4.75 12 4.75z" />
260
303
  </svg>
261
304
  );
262
305
  }
263
306
 
264
- // Map the framework's auth error codes to friendly copy. `ApiError` carries a
265
- // stable `.code` (and `.status`) so you branch on the code, not the message.
266
307
  function messageFor(err: unknown): string {
267
308
  if (err instanceof ApiError) {
268
309
  switch (err.code) {
@@ -0,0 +1,20 @@
1
+ import React from "react";
2
+ import { type Metadata } from "@pylonsync/react";
3
+ import { ForgotPasswordForm } from "../recovery-forms";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "Reset your password — Acme",
7
+ robots: "noindex",
8
+ };
9
+
10
+ export default function ForgotPasswordPage() {
11
+ return (
12
+ <>
13
+ <h1 className="mt-5 text-[22px] font-semibold tracking-tight text-zinc-900">Reset your password</h1>
14
+ <p className="mt-1 text-[13px] text-zinc-500">Enter your email and we will send a reset link.</p>
15
+ <div className="mt-6">
16
+ <ForgotPasswordForm />
17
+ </div>
18
+ </>
19
+ );
20
+ }
@@ -0,0 +1,135 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import { persistSession } from "@pylonsync/client";
5
+ import { Link } from "@pylonsync/react";
6
+
7
+ const inputCls =
8
+ "h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-zinc-900 focus:ring-2 focus:ring-zinc-900/10";
9
+ const buttonCls =
10
+ "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";
11
+
12
+ async function post(path: string, body: unknown): Promise<Record<string, unknown>> {
13
+ const res = await fetch(path, {
14
+ method: "POST",
15
+ headers: { "content-type": "application/json" },
16
+ credentials: "include",
17
+ body: JSON.stringify(body),
18
+ });
19
+ const data = (await res.json().catch(() => ({}))) as {
20
+ error?: { code?: string; message?: string };
21
+ };
22
+ if (!res.ok) throw new Error(data.error?.message ?? `Request failed (${res.status})`);
23
+ return data as Record<string, unknown>;
24
+ }
25
+
26
+ /** /forgot-password: request a reset email. Always says "sent" so the form
27
+ * does not reveal whether an address has an account. */
28
+ export function ForgotPasswordForm() {
29
+ const [email, setEmail] = useState("");
30
+ const [sent, setSent] = useState(false);
31
+ const [pending, setPending] = useState(false);
32
+ const [error, setError] = useState<string | null>(null);
33
+
34
+ async function onSubmit(e: React.FormEvent) {
35
+ e.preventDefault();
36
+ setPending(true);
37
+ setError(null);
38
+ try {
39
+ await post("/api/auth/password/reset/request", { email });
40
+ setSent(true);
41
+ } catch (err) {
42
+ setError(err instanceof Error ? err.message : "Something went wrong.");
43
+ } finally {
44
+ setPending(false);
45
+ }
46
+ }
47
+
48
+ if (sent) {
49
+ return (
50
+ <p className="text-sm text-zinc-600">
51
+ If an account exists for <span className="font-medium">{email}</span>, a reset link is on its way. The
52
+ link expires in one hour.
53
+ </p>
54
+ );
55
+ }
56
+ return (
57
+ <form onSubmit={onSubmit} className="space-y-4">
58
+ <input
59
+ type="email"
60
+ required
61
+ autoComplete="email"
62
+ value={email}
63
+ onChange={(e) => setEmail(e.target.value)}
64
+ placeholder="you@company.com"
65
+ aria-label="Email"
66
+ className={inputCls}
67
+ />
68
+ {error && <p className="text-[13px] text-red-700">{error}</p>}
69
+ <button type="submit" disabled={pending} className={buttonCls}>
70
+ {pending ? "…" : "Send reset link"}
71
+ </button>
72
+ <p className="text-center text-[13px] text-zinc-500">
73
+ <Link href="/login" className="underline underline-offset-2">
74
+ Back to sign in
75
+ </Link>
76
+ </p>
77
+ </form>
78
+ );
79
+ }
80
+
81
+ /** /reset-password?token=…: set a new password and sign in. */
82
+ export function ResetPasswordForm({ token }: { token: string }) {
83
+ const [password, setPassword] = useState("");
84
+ const [pending, setPending] = useState(false);
85
+ const [error, setError] = useState<string | null>(null);
86
+
87
+ async function onSubmit(e: React.FormEvent) {
88
+ e.preventDefault();
89
+ setPending(true);
90
+ setError(null);
91
+ try {
92
+ const data = await post("/api/auth/password/reset/complete", { token, newPassword: password });
93
+ if (typeof data.token === "string") {
94
+ persistSession({ token: data.token, user_id: (data.user_id as string) ?? null });
95
+ window.location.assign("/dashboard");
96
+ return;
97
+ }
98
+ window.location.assign("/login?reset=1");
99
+ } catch (err) {
100
+ setError(err instanceof Error ? err.message : "That link is no longer valid.");
101
+ setPending(false);
102
+ }
103
+ }
104
+
105
+ if (!token) {
106
+ return (
107
+ <p className="text-sm text-zinc-600">
108
+ This reset link is missing its token.{" "}
109
+ <Link href="/forgot-password" className="underline underline-offset-2">
110
+ Request a new one
111
+ </Link>
112
+ .
113
+ </p>
114
+ );
115
+ }
116
+ return (
117
+ <form onSubmit={onSubmit} className="space-y-4">
118
+ <input
119
+ type="password"
120
+ required
121
+ minLength={10}
122
+ autoComplete="new-password"
123
+ value={password}
124
+ onChange={(e) => setPassword(e.target.value)}
125
+ placeholder="New password (10+ characters)"
126
+ aria-label="New password"
127
+ className={inputCls}
128
+ />
129
+ {error && <p className="text-[13px] text-red-700">{error}</p>}
130
+ <button type="submit" disabled={pending} className={buttonCls}>
131
+ {pending ? "…" : "Set new password"}
132
+ </button>
133
+ </form>
134
+ );
135
+ }
@@ -0,0 +1,24 @@
1
+ import React from "react";
2
+ import { type Metadata, type PageProps } from "@pylonsync/react";
3
+ import { ResetPasswordForm } from "../recovery-forms";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "Choose a new password — Acme",
7
+ robots: "noindex",
8
+ };
9
+
10
+ // The reset email links here with `?token=…` (see PYLON_PUBLIC_URL on the
11
+ // server: it builds `<public url>/reset-password?token=…`).
12
+ export default function ResetPasswordPage({ searchParams }: PageProps) {
13
+ const raw = (searchParams as Record<string, string | string[] | undefined> | undefined)?.token;
14
+ const token = Array.isArray(raw) ? raw[0] ?? "" : raw ?? "";
15
+ return (
16
+ <>
17
+ <h1 className="mt-5 text-[22px] font-semibold tracking-tight text-zinc-900">Choose a new password</h1>
18
+ <p className="mt-1 text-[13px] text-zinc-500">You will be signed in on every device after this.</p>
19
+ <div className="mt-6">
20
+ <ResetPasswordForm token={token} />
21
+ </div>
22
+ </>
23
+ );
24
+ }
@@ -198,7 +198,7 @@ export default function MarketingLayout({ children, auth }: LayoutProps) {
198
198
  <NavDropdown label="Product" items={PRODUCT_MENU} />
199
199
  <NavDropdown label="Resources" items={RESOURCES_MENU} />
200
200
  <Link
201
- href="/#pricing"
201
+ href="/pricing"
202
202
  className="text-[13.5px] text-zinc-600 transition-colors hover:text-zinc-900"
203
203
  >
204
204
  Pricing
@@ -14,6 +14,7 @@ import {
14
14
  Terminal,
15
15
  } from "@/components/marketing";
16
16
  import { siteConfig, productBySlug, type Product } from "@/lib/site.config";
17
+ import { PricingTable } from "@/components/pricing-table";
17
18
 
18
19
  // SEO metadata. Exported `metadata` is rendered into <head> on the server, so
19
20
  // this marketing page is fully indexable — view source and the copy is in the
@@ -306,58 +307,8 @@ export default function LandingPage({ auth }: PageProps) {
306
307
  <p className="mt-5 max-w-md text-[15px] leading-relaxed text-zinc-500">
307
308
  {pricing.body}
308
309
  </p>
309
- <div className="mt-12 grid gap-5 lg:grid-cols-3">
310
- {pricing.plans.map((p) => (
311
- <div
312
- key={p.name}
313
- className={`flex flex-col rounded-2xl border p-7 ${
314
- p.featured
315
- ? "border-zinc-900 bg-white shadow-[0_24px_60px_-30px_rgba(0,0,0,0.3)]"
316
- : "border-zinc-200 bg-paper"
317
- }`}
318
- >
319
- <div className="flex items-center justify-between">
320
- <h3 className="text-base font-semibold">{p.name}</h3>
321
- {p.featured && (
322
- <span className="font-mono text-[10px] uppercase tracking-[0.12em] text-brand">
323
- Most popular
324
- </span>
325
- )}
326
- </div>
327
- <p className="mt-1 text-[13px] text-zinc-500">{p.tagline}</p>
328
- <div className="mt-5 flex items-baseline gap-1">
329
- <span className="text-4xl font-semibold tracking-tight">
330
- {p.price}
331
- </span>
332
- <span className="text-[13px] text-zinc-500">{p.unit}</span>
333
- </div>
334
- <ul className="mt-6 flex-1 space-y-3 text-[14px] text-zinc-600">
335
- {p.features.map((f) => (
336
- <li key={f} className="flex gap-2.5">
337
- <span className="mt-[3px] text-brand">✓</span>
338
- {f}
339
- </li>
340
- ))}
341
- </ul>
342
- <div className="mt-7">
343
- {p.featured ? (
344
- <PrimaryButton
345
- href={primaryHref}
346
- className="w-full justify-center"
347
- >
348
- {p.cta}
349
- </PrimaryButton>
350
- ) : (
351
- <Link
352
- href={primaryHref}
353
- className="inline-flex w-full items-center justify-center rounded-full border border-zinc-300 px-5 py-2.5 text-sm font-medium text-zinc-900 transition-colors hover:border-zinc-400 hover:bg-zinc-50"
354
- >
355
- {p.cta}
356
- </Link>
357
- )}
358
- </div>
359
- </div>
360
- ))}
310
+ <div className="mt-12">
311
+ <PricingTable signedIn={signedIn} />
361
312
  </div>
362
313
  </section>
363
314