@pylonsync/create-pylon 0.9.1 → 0.10.0
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/package.json +1 -1
- package/templates/_root/AGENTS.md +11 -0
- package/templates/agency/AGENTS.md +11 -0
- package/templates/ai-chat/AGENTS.md +11 -0
- package/templates/ai-studio/AGENTS.md +11 -0
- package/templates/backend/mobile/apps/api/README.md +20 -1
- package/templates/backend/mobile/apps/api/app/(site)/layout.tsx +103 -0
- package/templates/backend/mobile/apps/api/app/(site)/not-found.tsx +23 -0
- package/templates/backend/mobile/apps/api/app/(site)/page.tsx +221 -0
- package/templates/backend/mobile/apps/api/app/(site)/privacy/page.tsx +131 -0
- package/templates/backend/mobile/apps/api/app/(site)/support/page.tsx +78 -0
- package/templates/backend/mobile/apps/api/app/(site)/terms/page.tsx +144 -0
- package/templates/backend/mobile/apps/api/app/error.tsx +27 -0
- package/templates/backend/mobile/apps/api/app/globals.css +87 -0
- package/templates/backend/mobile/apps/api/app/layout.tsx +27 -0
- package/templates/backend/mobile/apps/api/app/robots.ts +13 -0
- package/templates/backend/mobile/apps/api/app/sitemap.ts +16 -0
- package/templates/backend/mobile/apps/api/app.ts +6 -2
- package/templates/backend/mobile/apps/api/components/legal.tsx +64 -0
- package/templates/backend/mobile/apps/api/lib/site.ts +94 -0
- package/templates/backend/mobile/apps/api/package.json +8 -1
- package/templates/backend/mobile/apps/api/tsconfig.json +6 -2
- package/templates/barebones/AGENTS.md +11 -0
- package/templates/chat/AGENTS.md +11 -0
- package/templates/consumer/AGENTS.md +11 -0
- package/templates/creator/AGENTS.md +11 -0
- package/templates/crm/AGENTS.md +11 -0
- package/templates/directory/AGENTS.md +11 -0
- package/templates/expo/mobile/apps/expo/.env.example +13 -4
- package/templates/expo/mobile/apps/expo/README.md +9 -0
- package/templates/expo/mobile/apps/expo/STORE.md +9 -5
- package/templates/expo/mobile/apps/expo/app/(tabs)/settings.tsx +9 -6
- package/templates/expo/mobile/apps/expo/app/paywall.tsx +9 -13
- package/templates/expo/mobile/apps/expo/src/links.ts +29 -0
- package/templates/helpdesk/AGENTS.md +11 -0
- package/templates/inventory/AGENTS.md +11 -0
- package/templates/invoices/AGENTS.md +11 -0
- package/templates/local-service/AGENTS.md +11 -0
- package/templates/marketplace/AGENTS.md +11 -0
- package/templates/projects/AGENTS.md +11 -0
- package/templates/restaurant/AGENTS.md +11 -0
- package/templates/saas/AGENTS.md +11 -0
- package/templates/saas/app/dashboard/layout.tsx +9 -2
- package/templates/saas/app/dashboard/page.tsx +37 -14
- package/templates/saas/app/dashboard/settings/page.tsx +11 -4
- package/templates/shop/AGENTS.md +11 -0
- package/templates/todo/AGENTS.md +11 -0
- package/templates/waitlist/AGENTS.md +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/create-pylon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
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"
|
|
@@ -40,6 +40,17 @@ this pass before the user has to ask.
|
|
|
40
40
|
## Key gotchas
|
|
41
41
|
|
|
42
42
|
- **Policies deny by default; server functions bypass them.** Direct client CRUD (`/api/entities/*`) and sync are policy-checked. Functions run with full database access, so enforce trust with `ctx.auth` checks inside the handler.
|
|
43
|
+
- **Never wrap `serverData` calls in `Promise.all`.** Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, so `use()` returns synchronously. `Promise.all` builds a new, pending, uncached promise on every render, so `use()` suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the first `use()`, then `use()` each handle — the reads overlap and the replayed render finds each one cached:
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
const orgPromise = serverData.get<Org>("Org", auth.tenant_id);
|
|
47
|
+
const projectsPromise = serverData.list<Project>("Project");
|
|
48
|
+
const org = use(orgPromise);
|
|
49
|
+
const projects = use(projectsPromise);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Reading them one at a time (`use(serverData.a()); use(serverData.b());`) is correct but serial: each read waits for the one above it.
|
|
53
|
+
|
|
43
54
|
- **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
|
|
44
55
|
- **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` or `<Suspense>`. The HTTP head commits when the shell is ready. Status, headers, and cookies set from a suspended subtree are lost, and `redirect` or `notFound` thrown below a Suspense boundary are swallowed.
|
|
45
56
|
- **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are available on mutations and actions, not queries.** An `action` has no direct `ctx.db`; use `ctx.runQuery` or `ctx.runMutation`.
|
|
@@ -48,6 +48,17 @@ this pass before the user has to ask.
|
|
|
48
48
|
- **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
|
|
49
49
|
- **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `pathname` / `searchParams` page props (`pathname` is the PATH only — the query is already parsed into `searchParams`, so never try to read a query parameter back out of it; the older `url` prop is the same value and is deprecated).
|
|
50
50
|
- **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
|
|
51
|
+
- **Never wrap `serverData` calls in `Promise.all`.** Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, so `use()` returns synchronously. `Promise.all` builds a new, pending, uncached promise on every render, so `use()` suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the first `use()`, then `use()` each handle — the reads overlap and the replayed render finds each one cached:
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
const orgPromise = serverData.get<Org>("Org", auth.tenant_id);
|
|
55
|
+
const projectsPromise = serverData.list<Project>("Project");
|
|
56
|
+
const org = use(orgPromise);
|
|
57
|
+
const projects = use(projectsPromise);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Reading them one at a time (`use(serverData.a()); use(serverData.b());`) is correct but serial: each read waits for the one above it.
|
|
61
|
+
|
|
51
62
|
- **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
|
|
52
63
|
- **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
|
|
53
64
|
- **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
|
|
@@ -48,6 +48,17 @@ this pass before the user has to ask.
|
|
|
48
48
|
- **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
|
|
49
49
|
- **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `pathname` / `searchParams` page props (`pathname` is the PATH only — the query is already parsed into `searchParams`, so never try to read a query parameter back out of it; the older `url` prop is the same value and is deprecated).
|
|
50
50
|
- **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
|
|
51
|
+
- **Never wrap `serverData` calls in `Promise.all`.** Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, so `use()` returns synchronously. `Promise.all` builds a new, pending, uncached promise on every render, so `use()` suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the first `use()`, then `use()` each handle — the reads overlap and the replayed render finds each one cached:
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
const orgPromise = serverData.get<Org>("Org", auth.tenant_id);
|
|
55
|
+
const projectsPromise = serverData.list<Project>("Project");
|
|
56
|
+
const org = use(orgPromise);
|
|
57
|
+
const projects = use(projectsPromise);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Reading them one at a time (`use(serverData.a()); use(serverData.b());`) is correct but serial: each read waits for the one above it.
|
|
61
|
+
|
|
51
62
|
- **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
|
|
52
63
|
- **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
|
|
53
64
|
- **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
|
|
@@ -48,6 +48,17 @@ this pass before the user has to ask.
|
|
|
48
48
|
- **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
|
|
49
49
|
- **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `pathname` / `searchParams` page props (`pathname` is the PATH only — the query is already parsed into `searchParams`, so never try to read a query parameter back out of it; the older `url` prop is the same value and is deprecated).
|
|
50
50
|
- **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
|
|
51
|
+
- **Never wrap `serverData` calls in `Promise.all`.** Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, so `use()` returns synchronously. `Promise.all` builds a new, pending, uncached promise on every render, so `use()` suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the first `use()`, then `use()` each handle — the reads overlap and the replayed render finds each one cached:
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
const orgPromise = serverData.get<Org>("Org", auth.tenant_id);
|
|
55
|
+
const projectsPromise = serverData.list<Project>("Project");
|
|
56
|
+
const org = use(orgPromise);
|
|
57
|
+
const projects = use(projectsPromise);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Reading them one at a time (`use(serverData.a()); use(serverData.b());`) is correct but serial: each read waits for the one above it.
|
|
61
|
+
|
|
51
62
|
- **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
|
|
52
63
|
- **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
|
|
53
64
|
- **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
|
|
@@ -9,8 +9,25 @@ functions/createNote server-enforced free-tier cap, then insert
|
|
|
9
9
|
functions/deleteMyData the user's rows, run by DELETE /api/auth/account
|
|
10
10
|
functions/revenuecatWebhook, syncEntitlements, _pylonRcUpsertEntitlement
|
|
11
11
|
lib/purchases.ts the RevenueCat plugin instance + FREE_NOTE_LIMIT
|
|
12
|
+
lib/site.ts everything the website says — edit this one file
|
|
13
|
+
app/(site)/ landing page, /support, /privacy, /terms
|
|
12
14
|
```
|
|
13
15
|
|
|
16
|
+
## The website
|
|
17
|
+
|
|
18
|
+
This one server answers the app's API calls and serves the public site, so
|
|
19
|
+
`/privacy` and `/terms` are live on the same host the moment you deploy. Both
|
|
20
|
+
stores refuse a submission without a reachable privacy policy URL, and the app
|
|
21
|
+
links to these pages from Settings and from the paywall.
|
|
22
|
+
|
|
23
|
+
Edit `lib/site.ts` for the name, the copy, store links, and your company
|
|
24
|
+
details. A banner sits on every page until those details are filled in,
|
|
25
|
+
because the legal text ships as a draft: it describes what this app actually
|
|
26
|
+
does, which makes it a real starting point, but a lawyer should read it before
|
|
27
|
+
you submit.
|
|
28
|
+
|
|
29
|
+
`bun run dev` serves the site at http://localhost:4321 alongside the API.
|
|
30
|
+
|
|
14
31
|
## Run
|
|
15
32
|
|
|
16
33
|
```bash
|
|
@@ -29,4 +46,6 @@ pylon secrets set REVENUECAT_WEBHOOK_AUTH=... REVENUECAT_SECRET_KEY=... \
|
|
|
29
46
|
```
|
|
30
47
|
|
|
31
48
|
Then set `EXPO_PUBLIC_PYLON_BASE_URL` in `apps/expo/.env` to the deployed URL
|
|
32
|
-
and point the RevenueCat webhook at `<url>/api/fn/revenuecatWebhook`.
|
|
49
|
+
and point the RevenueCat webhook at `<url>/api/fn/revenuecatWebhook`. Put the
|
|
50
|
+
same URL in `lib/site.ts` as `url`, and give App Store Connect `<url>/privacy`
|
|
51
|
+
and `<url>/support`.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link } from "@pylonsync/react";
|
|
3
|
+
import { missingSiteConfig, site } from "@/lib/site";
|
|
4
|
+
|
|
5
|
+
interface LayoutProps {
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `(site)` is a route group: the parens segment never appears in a URL, so
|
|
11
|
+
* `(site)/page.tsx` still serves `/`. This layout owns the chrome for every
|
|
12
|
+
* public page. Keep it here rather than checking the pathname in a component,
|
|
13
|
+
* so a page that should not have the header simply lives outside the group.
|
|
14
|
+
*/
|
|
15
|
+
export default function SiteLayout({ children }: LayoutProps) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex min-h-screen flex-col">
|
|
18
|
+
<SetupNotice />
|
|
19
|
+
<Header />
|
|
20
|
+
<main className="flex-1">{children}</main>
|
|
21
|
+
<Footer />
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Renders while `lib/site.ts` still has blank fields. The legal pages below
|
|
28
|
+
* are a starting point with your company details filled in by that file, so
|
|
29
|
+
* an unfinished site should say so out loud rather than publish placeholder
|
|
30
|
+
* terms. It disappears on its own once the fields are set.
|
|
31
|
+
*/
|
|
32
|
+
function SetupNotice() {
|
|
33
|
+
const missing = missingSiteConfig();
|
|
34
|
+
if (missing.length === 0) return null;
|
|
35
|
+
return (
|
|
36
|
+
<div className="border-b border-amber-300 bg-amber-50 px-4 py-3 text-center text-[13px] text-amber-900">
|
|
37
|
+
Finish <code className="font-mono">lib/site.ts</code> before you publish
|
|
38
|
+
this site. Still blank: {missing.join(", ")}. The privacy policy and
|
|
39
|
+
terms need your details, and a lawyer should read them.
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function Header() {
|
|
45
|
+
return (
|
|
46
|
+
<header className="sticky top-0 z-20 border-b border-line bg-surface/85 backdrop-blur">
|
|
47
|
+
<div className="mx-auto flex h-16 max-w-5xl items-center justify-between px-6">
|
|
48
|
+
<Link href="/" className="text-[15px] font-semibold tracking-tight">
|
|
49
|
+
{site.name}
|
|
50
|
+
</Link>
|
|
51
|
+
<nav className="flex items-center gap-6 text-[13.5px] text-ink-muted">
|
|
52
|
+
<a href="/#features" className="hidden transition-colors hover:text-ink sm:block">
|
|
53
|
+
Features
|
|
54
|
+
</a>
|
|
55
|
+
<a href="/#faq" className="hidden transition-colors hover:text-ink sm:block">
|
|
56
|
+
Questions
|
|
57
|
+
</a>
|
|
58
|
+
<Link href="/support" className="transition-colors hover:text-ink">
|
|
59
|
+
Support
|
|
60
|
+
</Link>
|
|
61
|
+
<a
|
|
62
|
+
href="/#get"
|
|
63
|
+
className="rounded-full bg-brand px-4 py-2 text-[13px] font-medium text-brand-ink transition-opacity hover:opacity-90"
|
|
64
|
+
>
|
|
65
|
+
Get the app
|
|
66
|
+
</a>
|
|
67
|
+
</nav>
|
|
68
|
+
</div>
|
|
69
|
+
</header>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function Footer() {
|
|
74
|
+
const year = new Date().getFullYear();
|
|
75
|
+
return (
|
|
76
|
+
<footer className="border-t border-line bg-surface-soft">
|
|
77
|
+
<div className="mx-auto flex max-w-5xl flex-col gap-4 px-6 py-10 text-[13px] text-ink-muted sm:flex-row sm:items-center sm:justify-between">
|
|
78
|
+
<p>
|
|
79
|
+
© {year} {site.company || site.name}
|
|
80
|
+
</p>
|
|
81
|
+
<nav className="flex flex-wrap items-center gap-5">
|
|
82
|
+
<Link href="/privacy" className="transition-colors hover:text-ink">
|
|
83
|
+
Privacy
|
|
84
|
+
</Link>
|
|
85
|
+
<Link href="/terms" className="transition-colors hover:text-ink">
|
|
86
|
+
Terms
|
|
87
|
+
</Link>
|
|
88
|
+
<Link href="/support" className="transition-colors hover:text-ink">
|
|
89
|
+
Support
|
|
90
|
+
</Link>
|
|
91
|
+
{site.supportEmail ? (
|
|
92
|
+
<a
|
|
93
|
+
href={`mailto:${site.supportEmail}`}
|
|
94
|
+
className="transition-colors hover:text-ink"
|
|
95
|
+
>
|
|
96
|
+
{site.supportEmail}
|
|
97
|
+
</a>
|
|
98
|
+
) : null}
|
|
99
|
+
</nav>
|
|
100
|
+
</div>
|
|
101
|
+
</footer>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, type NotFoundProps } from "@pylonsync/react";
|
|
3
|
+
|
|
4
|
+
// `not-found.tsx` inside the route group, so a missing page still renders
|
|
5
|
+
// with the site header and footer around it.
|
|
6
|
+
export default function NotFound(_props: NotFoundProps) {
|
|
7
|
+
return (
|
|
8
|
+
<div className="mx-auto max-w-3xl px-6 py-24 text-center">
|
|
9
|
+
<h1 className="text-3xl font-semibold tracking-[-0.02em]">
|
|
10
|
+
Page not found
|
|
11
|
+
</h1>
|
|
12
|
+
<p className="mt-3 text-[15px] text-ink-muted">
|
|
13
|
+
That link does not go anywhere.
|
|
14
|
+
</p>
|
|
15
|
+
<Link
|
|
16
|
+
href="/"
|
|
17
|
+
className="mt-8 inline-block rounded-xl bg-ink px-5 py-3 text-[14px] font-medium text-surface transition-opacity hover:opacity-90"
|
|
18
|
+
>
|
|
19
|
+
Back to the home page
|
|
20
|
+
</Link>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type Metadata } from "@pylonsync/react";
|
|
3
|
+
import { site } from "@/lib/site";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: `${site.name} — ${site.tagline}`,
|
|
7
|
+
description: site.description,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The landing page, served at `/` by the same Pylon server that answers the
|
|
12
|
+
* mobile app's API calls. That is why the legal pages below are a real URL
|
|
13
|
+
* the moment you deploy: the App Store and Play Store both refuse a
|
|
14
|
+
* submission without one.
|
|
15
|
+
*
|
|
16
|
+
* Replace the copy through `lib/site.ts`. The sections themselves are here.
|
|
17
|
+
*/
|
|
18
|
+
export default function LandingPage() {
|
|
19
|
+
return (
|
|
20
|
+
<>
|
|
21
|
+
<Hero />
|
|
22
|
+
<Features />
|
|
23
|
+
<Pricing />
|
|
24
|
+
<Faq />
|
|
25
|
+
<GetTheApp />
|
|
26
|
+
</>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function Hero() {
|
|
31
|
+
return (
|
|
32
|
+
<section className="mx-auto max-w-5xl px-6 pb-16 pt-20 sm:pt-28">
|
|
33
|
+
<div className="grid items-center gap-14 lg:grid-cols-[1.05fr_0.95fr]">
|
|
34
|
+
<div>
|
|
35
|
+
<h1 className="text-4xl font-semibold leading-[1.08] tracking-[-0.02em] sm:text-5xl">
|
|
36
|
+
{site.tagline}
|
|
37
|
+
</h1>
|
|
38
|
+
<p className="mt-5 max-w-xl text-[16.5px] leading-relaxed text-ink-muted">
|
|
39
|
+
{site.description}
|
|
40
|
+
</p>
|
|
41
|
+
<div className="mt-8">
|
|
42
|
+
<StoreButtons />
|
|
43
|
+
</div>
|
|
44
|
+
<p className="mt-4 text-[13px] text-ink-muted">
|
|
45
|
+
Free for your first {site.pricing.freeLimit} notes. No account
|
|
46
|
+
needed to start.
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
<PhoneShot />
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A real link once the store URL is in `lib/site.ts`, and an honest "not yet"
|
|
57
|
+
* until then. A dead link on a launch page is worse than saying nothing.
|
|
58
|
+
*/
|
|
59
|
+
function StoreButtons() {
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex flex-wrap gap-3">
|
|
62
|
+
<StoreButton
|
|
63
|
+
href={site.appStoreUrl}
|
|
64
|
+
label="Download on the App Store"
|
|
65
|
+
pending="Coming to the App Store"
|
|
66
|
+
/>
|
|
67
|
+
<StoreButton
|
|
68
|
+
href={site.playStoreUrl}
|
|
69
|
+
label="Get it on Google Play"
|
|
70
|
+
pending="Coming to Google Play"
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function StoreButton({
|
|
77
|
+
href,
|
|
78
|
+
label,
|
|
79
|
+
pending,
|
|
80
|
+
}: {
|
|
81
|
+
href: string;
|
|
82
|
+
label: string;
|
|
83
|
+
pending: string;
|
|
84
|
+
}) {
|
|
85
|
+
if (!href) {
|
|
86
|
+
return (
|
|
87
|
+
<span className="rounded-xl border border-dashed border-line px-5 py-3 text-[14px] text-ink-muted">
|
|
88
|
+
{pending}
|
|
89
|
+
</span>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
<a
|
|
94
|
+
href={href}
|
|
95
|
+
className="rounded-xl bg-ink px-5 py-3 text-[14px] font-medium text-surface transition-opacity hover:opacity-90"
|
|
96
|
+
>
|
|
97
|
+
{label}
|
|
98
|
+
</a>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Placeholder, marked as one. Swap it for a real screenshot before launch. */
|
|
103
|
+
function PhoneShot() {
|
|
104
|
+
return (
|
|
105
|
+
<div className="mx-auto w-full max-w-[280px]">
|
|
106
|
+
<div className="rounded-[2.25rem] border border-line bg-surface-soft p-3 shadow-[0_30px_80px_-40px_rgba(0,0,0,0.45)]">
|
|
107
|
+
<div className="flex aspect-[9/19.5] flex-col items-center justify-center gap-3 rounded-[1.75rem] border-2 border-dashed border-line px-6 text-center">
|
|
108
|
+
<svg
|
|
109
|
+
width="26"
|
|
110
|
+
height="26"
|
|
111
|
+
viewBox="0 0 24 24"
|
|
112
|
+
fill="none"
|
|
113
|
+
stroke="currentColor"
|
|
114
|
+
strokeWidth="1.5"
|
|
115
|
+
className="text-ink-muted"
|
|
116
|
+
aria-hidden="true"
|
|
117
|
+
>
|
|
118
|
+
<rect x="3" y="5" width="18" height="14" rx="2" />
|
|
119
|
+
<circle cx="8.5" cy="10" r="1.5" />
|
|
120
|
+
<path d="m21 16-5-5-6 6-3-3-4 4" />
|
|
121
|
+
</svg>
|
|
122
|
+
<p className="text-[13px] font-medium">App screenshot</p>
|
|
123
|
+
<p className="text-[12px] leading-snug text-ink-muted">
|
|
124
|
+
Drop a 1290×2796 screenshot in here. The component is
|
|
125
|
+
<code className="mx-1 font-mono">PhoneShot</code>
|
|
126
|
+
in this file.
|
|
127
|
+
</p>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function Features() {
|
|
135
|
+
return (
|
|
136
|
+
<section id="features" className="border-t border-line bg-surface-soft">
|
|
137
|
+
<div className="mx-auto max-w-5xl px-6 py-20">
|
|
138
|
+
<h2 className="max-w-2xl text-2xl font-semibold tracking-[-0.015em] sm:text-3xl">
|
|
139
|
+
What it does
|
|
140
|
+
</h2>
|
|
141
|
+
<div className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
142
|
+
{site.features.map((feature) => (
|
|
143
|
+
<div
|
|
144
|
+
key={feature.title}
|
|
145
|
+
className="rounded-2xl border border-line bg-surface p-6"
|
|
146
|
+
>
|
|
147
|
+
<h3 className="text-[15px] font-semibold">{feature.title}</h3>
|
|
148
|
+
<p className="mt-2 text-[14px] leading-relaxed text-ink-muted">
|
|
149
|
+
{feature.body}
|
|
150
|
+
</p>
|
|
151
|
+
</div>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</section>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function Pricing() {
|
|
160
|
+
return (
|
|
161
|
+
<section className="mx-auto max-w-5xl px-6 py-20">
|
|
162
|
+
<h2 className="text-2xl font-semibold tracking-[-0.015em] sm:text-3xl">
|
|
163
|
+
What it costs
|
|
164
|
+
</h2>
|
|
165
|
+
<div className="mt-10 grid gap-6 sm:grid-cols-2">
|
|
166
|
+
<div className="rounded-2xl border border-line p-7">
|
|
167
|
+
<h3 className="text-[15px] font-semibold">Free</h3>
|
|
168
|
+
<p className="mt-2 text-[14px] leading-relaxed text-ink-muted">
|
|
169
|
+
Up to {site.pricing.freeLimit} notes, synced across your devices.
|
|
170
|
+
Nothing expires.
|
|
171
|
+
</p>
|
|
172
|
+
</div>
|
|
173
|
+
<div className="rounded-2xl border border-brand bg-brand-soft p-7">
|
|
174
|
+
<h3 className="text-[15px] font-semibold">Pro</h3>
|
|
175
|
+
<p className="mt-2 text-[14px] leading-relaxed text-ink-muted">
|
|
176
|
+
{site.pricing.proBlurb} Prices are set per country in the store and
|
|
177
|
+
shown in the app before you buy.
|
|
178
|
+
</p>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</section>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function Faq() {
|
|
186
|
+
return (
|
|
187
|
+
<section id="faq" className="border-t border-line bg-surface-soft">
|
|
188
|
+
<div className="mx-auto max-w-3xl px-6 py-20">
|
|
189
|
+
<h2 className="text-2xl font-semibold tracking-[-0.015em] sm:text-3xl">
|
|
190
|
+
Questions
|
|
191
|
+
</h2>
|
|
192
|
+
<dl className="mt-10 divide-y divide-line border-y border-line">
|
|
193
|
+
{site.faq.map((item) => (
|
|
194
|
+
<div key={item.q} className="py-5">
|
|
195
|
+
<dt className="text-[15px] font-medium">{item.q}</dt>
|
|
196
|
+
<dd className="mt-2 text-[14px] leading-relaxed text-ink-muted">
|
|
197
|
+
{item.a}
|
|
198
|
+
</dd>
|
|
199
|
+
</div>
|
|
200
|
+
))}
|
|
201
|
+
</dl>
|
|
202
|
+
</div>
|
|
203
|
+
</section>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function GetTheApp() {
|
|
208
|
+
return (
|
|
209
|
+
<section id="get" className="mx-auto max-w-5xl px-6 py-20 text-center">
|
|
210
|
+
<h2 className="text-2xl font-semibold tracking-[-0.015em] sm:text-3xl">
|
|
211
|
+
Get {site.name}
|
|
212
|
+
</h2>
|
|
213
|
+
<p className="mx-auto mt-3 max-w-md text-[15px] leading-relaxed text-ink-muted">
|
|
214
|
+
Free to start, on iPhone and Android.
|
|
215
|
+
</p>
|
|
216
|
+
<div className="mt-8 flex justify-center">
|
|
217
|
+
<StoreButtons />
|
|
218
|
+
</div>
|
|
219
|
+
</section>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type Metadata } from "@pylonsync/react";
|
|
3
|
+
import { Company, LegalPage } from "@/components/legal";
|
|
4
|
+
import { site } from "@/lib/site";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: `Privacy Policy — ${site.name}`,
|
|
8
|
+
description: `How ${site.name} handles your data.`,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Both stores require a reachable privacy policy URL before they will accept
|
|
13
|
+
* a build, and Apple checks that it matches the App Privacy answers you give
|
|
14
|
+
* in App Store Connect. This text describes what the template's app really
|
|
15
|
+
* does: accounts, notes, and store subscriptions, with no advertising or
|
|
16
|
+
* tracking. Change it as your app changes.
|
|
17
|
+
*/
|
|
18
|
+
export default function PrivacyPage() {
|
|
19
|
+
return (
|
|
20
|
+
<LegalPage title="Privacy Policy">
|
|
21
|
+
<p>
|
|
22
|
+
{site.name} is published by <Company />. This page explains what we
|
|
23
|
+
collect, why we have it, and how to get rid of it.
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<h2>What we collect</h2>
|
|
27
|
+
<ul>
|
|
28
|
+
<li>
|
|
29
|
+
<strong>Your account.</strong> An email address when you sign in with
|
|
30
|
+
a code, or the account identifier and email that Apple or Google
|
|
31
|
+
gives us when you use their sign-in. Apple lets you hide your real
|
|
32
|
+
address, and that works here.
|
|
33
|
+
</li>
|
|
34
|
+
<li>
|
|
35
|
+
<strong>What you write.</strong> The notes you create, so we can
|
|
36
|
+
store them and sync them to your other devices.
|
|
37
|
+
</li>
|
|
38
|
+
<li>
|
|
39
|
+
<strong>Your subscription status.</strong> Whether a subscription is
|
|
40
|
+
active, and which product it is. Apple and Google take the payment.
|
|
41
|
+
We never receive your card details.
|
|
42
|
+
</li>
|
|
43
|
+
<li>
|
|
44
|
+
<strong>Technical records.</strong> Ordinary server logs, including
|
|
45
|
+
the internet address your device connects from and the time of the
|
|
46
|
+
request. We use them to keep the service running and to spot abuse.
|
|
47
|
+
</li>
|
|
48
|
+
</ul>
|
|
49
|
+
<p>
|
|
50
|
+
You can use the app without an account. In that case the app still
|
|
51
|
+
creates an anonymous identifier so your notes can sync to the server
|
|
52
|
+
and back. Sign in later and that content moves to your account.
|
|
53
|
+
</p>
|
|
54
|
+
|
|
55
|
+
<h2>What we do not do</h2>
|
|
56
|
+
<ul>
|
|
57
|
+
<li>We do not sell your data.</li>
|
|
58
|
+
<li>
|
|
59
|
+
We do not use it for advertising, and we do not track you across
|
|
60
|
+
other apps or websites.
|
|
61
|
+
</li>
|
|
62
|
+
<li>We do not read your notes, except when you ask us to help.</li>
|
|
63
|
+
</ul>
|
|
64
|
+
|
|
65
|
+
<h2>Who else touches it</h2>
|
|
66
|
+
<p>
|
|
67
|
+
We use a small number of companies to run the service, and they may
|
|
68
|
+
process data on our behalf:
|
|
69
|
+
</p>
|
|
70
|
+
<ul>
|
|
71
|
+
<li>Apple and Google, for sign-in, payments, and app distribution.</li>
|
|
72
|
+
<li>RevenueCat, to tell us whether a subscription is active.</li>
|
|
73
|
+
<li>Our hosting provider, which stores the data and serves the API.</li>
|
|
74
|
+
<li>Our email provider, to deliver sign-in codes.</li>
|
|
75
|
+
</ul>
|
|
76
|
+
|
|
77
|
+
<h2>How long we keep it</h2>
|
|
78
|
+
<p>
|
|
79
|
+
Your notes stay until you delete them or delete your account. Logs are
|
|
80
|
+
kept for a short period and then discarded.
|
|
81
|
+
</p>
|
|
82
|
+
|
|
83
|
+
<h2>Deleting your account</h2>
|
|
84
|
+
<p>
|
|
85
|
+
Open the app and go to Settings, then Delete account. This removes your
|
|
86
|
+
account and the notes attached to it. It happens immediately and it
|
|
87
|
+
cannot be undone. A subscription is billed by the store, so cancel it
|
|
88
|
+
in your App Store or Play Store account as well.
|
|
89
|
+
</p>
|
|
90
|
+
|
|
91
|
+
<h2>Your rights</h2>
|
|
92
|
+
<p>
|
|
93
|
+
You can ask what we hold about you, ask for a copy, ask us to correct
|
|
94
|
+
it, or ask us to delete it. Write to us at the address below. Depending
|
|
95
|
+
on where you live, you may have further rights under local law.
|
|
96
|
+
</p>
|
|
97
|
+
|
|
98
|
+
<h2>Children</h2>
|
|
99
|
+
<p>
|
|
100
|
+
{site.name} is not directed at children under 13, and we do not
|
|
101
|
+
knowingly collect their data. If you believe a child has given us
|
|
102
|
+
information, contact us and we will remove it.
|
|
103
|
+
</p>
|
|
104
|
+
|
|
105
|
+
<h2>Security</h2>
|
|
106
|
+
<p>
|
|
107
|
+
Traffic between the app and our servers is encrypted in transit. We
|
|
108
|
+
limit who can reach production data. No service can promise perfect
|
|
109
|
+
security, and we do not.
|
|
110
|
+
</p>
|
|
111
|
+
|
|
112
|
+
<h2>Changes</h2>
|
|
113
|
+
<p>
|
|
114
|
+
If this policy changes in a way that matters, we will update the date
|
|
115
|
+
at the top of this page and, where the change is significant, tell you
|
|
116
|
+
in the app.
|
|
117
|
+
</p>
|
|
118
|
+
|
|
119
|
+
<h2>Contact</h2>
|
|
120
|
+
<p>
|
|
121
|
+
<Company />
|
|
122
|
+
{site.address ? <>, {site.address}</> : null}
|
|
123
|
+
{site.supportEmail ? (
|
|
124
|
+
<>
|
|
125
|
+
. Email <a href={`mailto:${site.supportEmail}`}>{site.supportEmail}</a>.
|
|
126
|
+
</>
|
|
127
|
+
) : null}
|
|
128
|
+
</p>
|
|
129
|
+
</LegalPage>
|
|
130
|
+
);
|
|
131
|
+
}
|