@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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/templates/_root/AGENTS.md +11 -0
  3. package/templates/agency/AGENTS.md +11 -0
  4. package/templates/ai-chat/AGENTS.md +11 -0
  5. package/templates/ai-studio/AGENTS.md +11 -0
  6. package/templates/backend/mobile/apps/api/README.md +20 -1
  7. package/templates/backend/mobile/apps/api/app/(site)/layout.tsx +103 -0
  8. package/templates/backend/mobile/apps/api/app/(site)/not-found.tsx +23 -0
  9. package/templates/backend/mobile/apps/api/app/(site)/page.tsx +221 -0
  10. package/templates/backend/mobile/apps/api/app/(site)/privacy/page.tsx +131 -0
  11. package/templates/backend/mobile/apps/api/app/(site)/support/page.tsx +78 -0
  12. package/templates/backend/mobile/apps/api/app/(site)/terms/page.tsx +144 -0
  13. package/templates/backend/mobile/apps/api/app/error.tsx +27 -0
  14. package/templates/backend/mobile/apps/api/app/globals.css +87 -0
  15. package/templates/backend/mobile/apps/api/app/layout.tsx +27 -0
  16. package/templates/backend/mobile/apps/api/app/robots.ts +13 -0
  17. package/templates/backend/mobile/apps/api/app/sitemap.ts +16 -0
  18. package/templates/backend/mobile/apps/api/app.ts +6 -2
  19. package/templates/backend/mobile/apps/api/components/legal.tsx +64 -0
  20. package/templates/backend/mobile/apps/api/lib/site.ts +94 -0
  21. package/templates/backend/mobile/apps/api/package.json +8 -1
  22. package/templates/backend/mobile/apps/api/tsconfig.json +6 -2
  23. package/templates/barebones/AGENTS.md +11 -0
  24. package/templates/chat/AGENTS.md +11 -0
  25. package/templates/consumer/AGENTS.md +11 -0
  26. package/templates/creator/AGENTS.md +11 -0
  27. package/templates/crm/AGENTS.md +11 -0
  28. package/templates/directory/AGENTS.md +11 -0
  29. package/templates/expo/mobile/apps/expo/.env.example +13 -4
  30. package/templates/expo/mobile/apps/expo/README.md +9 -0
  31. package/templates/expo/mobile/apps/expo/STORE.md +9 -5
  32. package/templates/expo/mobile/apps/expo/app/(tabs)/settings.tsx +9 -6
  33. package/templates/expo/mobile/apps/expo/app/paywall.tsx +9 -13
  34. package/templates/expo/mobile/apps/expo/src/links.ts +29 -0
  35. package/templates/helpdesk/AGENTS.md +11 -0
  36. package/templates/inventory/AGENTS.md +11 -0
  37. package/templates/invoices/AGENTS.md +11 -0
  38. package/templates/local-service/AGENTS.md +11 -0
  39. package/templates/marketplace/AGENTS.md +11 -0
  40. package/templates/projects/AGENTS.md +11 -0
  41. package/templates/restaurant/AGENTS.md +11 -0
  42. package/templates/saas/AGENTS.md +11 -0
  43. package/templates/saas/app/dashboard/layout.tsx +9 -2
  44. package/templates/saas/app/dashboard/page.tsx +37 -14
  45. package/templates/saas/app/dashboard/settings/page.tsx +11 -4
  46. package/templates/shop/AGENTS.md +11 -0
  47. package/templates/todo/AGENTS.md +11 -0
  48. 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.9.1",
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
+ }