@pylonsync/create-pylon 0.9.0 → 0.9.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 (37) hide show
  1. package/package.json +2 -2
  2. package/templates/_root/gitignore +1 -0
  3. package/templates/backend/mobile/apps/api/README.md +21 -1
  4. package/templates/backend/mobile/apps/api/app/(site)/layout.tsx +103 -0
  5. package/templates/backend/mobile/apps/api/app/(site)/not-found.tsx +23 -0
  6. package/templates/backend/mobile/apps/api/app/(site)/page.tsx +221 -0
  7. package/templates/backend/mobile/apps/api/app/(site)/privacy/page.tsx +131 -0
  8. package/templates/backend/mobile/apps/api/app/(site)/support/page.tsx +78 -0
  9. package/templates/backend/mobile/apps/api/app/(site)/terms/page.tsx +144 -0
  10. package/templates/backend/mobile/apps/api/app/error.tsx +27 -0
  11. package/templates/backend/mobile/apps/api/app/globals.css +87 -0
  12. package/templates/backend/mobile/apps/api/app/layout.tsx +27 -0
  13. package/templates/backend/mobile/apps/api/app/robots.ts +13 -0
  14. package/templates/backend/mobile/apps/api/app/sitemap.ts +16 -0
  15. package/templates/backend/mobile/apps/api/app.ts +11 -3
  16. package/templates/backend/mobile/apps/api/components/legal.tsx +64 -0
  17. package/templates/backend/mobile/apps/api/functions/createNote.ts +5 -1
  18. package/templates/backend/mobile/apps/api/functions/deleteMyData.ts +38 -0
  19. package/templates/backend/mobile/apps/api/lib/purchases.ts +8 -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/expo/mobile/apps/expo/.env.example +13 -4
  24. package/templates/expo/mobile/apps/expo/README.md +35 -2
  25. package/templates/expo/mobile/apps/expo/STORE.md +31 -7
  26. package/templates/expo/mobile/apps/expo/app/(auth)/sign-in.tsx +17 -6
  27. package/templates/expo/mobile/apps/expo/app/(auth)/verify.tsx +3 -1
  28. package/templates/expo/mobile/apps/expo/app/(tabs)/settings.tsx +15 -7
  29. package/templates/expo/mobile/apps/expo/app/paywall.tsx +9 -13
  30. package/templates/expo/mobile/apps/expo/app.config.ts +29 -5
  31. package/templates/expo/mobile/apps/expo/babel.config.js +2 -2
  32. package/templates/expo/mobile/apps/expo/eas.json +6 -5
  33. package/templates/expo/mobile/apps/expo/package.json +9 -5
  34. package/templates/expo/mobile/apps/expo/src/entitlements.ts +4 -9
  35. package/templates/expo/mobile/apps/expo/src/links.ts +29 -0
  36. package/templates/expo/mobile/apps/expo/src/pylon.ts +7 -2
  37. package/templates/expo/mobile/apps/expo/src/session.tsx +69 -23
@@ -0,0 +1,144 @@
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: `Terms of Service — ${site.name}`,
8
+ description: `The agreement between you and ${site.name}.`,
9
+ };
10
+
11
+ /**
12
+ * The terms the app links to from the paywall and from Settings. Apple
13
+ * requires subscription terms to be visible next to the purchase, and this
14
+ * page is where the detail lives. Keep the billing section true to what your
15
+ * products actually do.
16
+ */
17
+ export default function TermsPage() {
18
+ return (
19
+ <LegalPage title="Terms of Service">
20
+ <p>
21
+ These terms are the agreement between you and <Company /> for the use
22
+ of {site.name}. Using the app means you accept them.
23
+ </p>
24
+
25
+ <h2>Who may use it</h2>
26
+ <p>
27
+ You need to be at least 13 years old. If you use {site.name} for an
28
+ organization, you confirm you may accept these terms for it.
29
+ </p>
30
+
31
+ <h2>Your account</h2>
32
+ <p>
33
+ You are responsible for what happens under your account, so keep
34
+ access to your email and devices secure. Tell us if you think someone
35
+ else has your account.
36
+ </p>
37
+
38
+ <h2>Free and paid</h2>
39
+ <p>
40
+ The free tier holds up to {site.pricing.freeLimit} notes. A
41
+ subscription lifts that limit and includes what we add later. We may
42
+ change what each tier includes, and we will not reduce what you have
43
+ already paid for during a period you have paid.
44
+ </p>
45
+
46
+ <h2>Billing</h2>
47
+ <ul>
48
+ <li>
49
+ Apple and Google sell and bill the subscription, not us. The price
50
+ you see in the app is the price they charge in your country.
51
+ </li>
52
+ <li>
53
+ A subscription renews automatically until you cancel it. Cancel at
54
+ least 24 hours before the period ends, in your App Store or Play
55
+ Store account settings.
56
+ </li>
57
+ <li>
58
+ Refunds follow the store&apos;s policy. We cannot issue a refund for
59
+ a purchase we did not process, so ask Apple or Google.
60
+ </li>
61
+ <li>
62
+ Deleting the app does not cancel a subscription. Cancel it in the
63
+ store.
64
+ </li>
65
+ </ul>
66
+
67
+ <h2>Your content</h2>
68
+ <p>
69
+ Your notes are yours. You give us only the permission we need to run
70
+ the service, which is to store your content, back it up, and send it to
71
+ the devices you sign in on. We claim no ownership of it and we do not
72
+ use it to train anything.
73
+ </p>
74
+ <p>
75
+ You are responsible for what you put in the app, and for having the
76
+ right to put it there.
77
+ </p>
78
+
79
+ <h2>Acceptable use</h2>
80
+ <p>Do not use {site.name} to:</p>
81
+ <ul>
82
+ <li>break the law, or store material that is illegal to hold;</li>
83
+ <li>
84
+ attack the service, work around its limits, or get at other
85
+ people&apos;s data;
86
+ </li>
87
+ <li>resell it as your own product.</li>
88
+ </ul>
89
+
90
+ <h2>Availability</h2>
91
+ <p>
92
+ We work to keep {site.name} running, but we do not promise it will be
93
+ available without interruption. We may change or stop features. If we
94
+ shut the service down, we will give reasonable notice and a way to
95
+ export your notes.
96
+ </p>
97
+
98
+ <h2>Ending it</h2>
99
+ <p>
100
+ You can stop at any time by deleting your account in Settings. We may
101
+ suspend or close an account that breaks these terms or puts the service
102
+ or other people at risk.
103
+ </p>
104
+
105
+ <h2>Disclaimer and liability</h2>
106
+ <p>
107
+ {site.name} is provided as it is, without warranties beyond those the
108
+ law requires. To the extent the law allows, <Company /> is not liable
109
+ for indirect or consequential loss, and our total liability is limited
110
+ to what you paid us in the 12 months before the claim.
111
+ </p>
112
+ <p>
113
+ Nothing here removes rights you have as a consumer that cannot be
114
+ removed by agreement.
115
+ </p>
116
+
117
+ <h2>Changes</h2>
118
+ <p>
119
+ We may update these terms. The date at the top shows the current
120
+ version, and we will tell you in the app about a significant change.
121
+ Continuing to use {site.name} after that means you accept the new
122
+ terms.
123
+ </p>
124
+
125
+ <h2>Governing law</h2>
126
+ <p>
127
+ These terms are governed by the laws of{" "}
128
+ {site.governingLaw || "[your jurisdiction]"}, without regard to
129
+ conflict of law rules.
130
+ </p>
131
+
132
+ <h2>Contact</h2>
133
+ <p>
134
+ <Company />
135
+ {site.address ? <>, {site.address}</> : null}
136
+ {site.supportEmail ? (
137
+ <>
138
+ . Email <a href={`mailto:${site.supportEmail}`}>{site.supportEmail}</a>.
139
+ </>
140
+ ) : null}
141
+ </p>
142
+ </LegalPage>
143
+ );
144
+ }
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+ import { type ErrorBoundaryProps } from "@pylonsync/react";
3
+
4
+ // The error boundary sits at the root, outside the route group, so it still
5
+ // renders if a layout itself throws. It is hydrated, so `reset()` works.
6
+ export default function ErrorBoundary({ error, reset }: ErrorBoundaryProps) {
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
+ Something went wrong
11
+ </h1>
12
+ <p className="mt-3 text-[15px] text-ink-muted">{error.message}</p>
13
+ {error.digest ? (
14
+ <p className="mt-1 text-[12px] text-ink-muted">
15
+ Reference: <code className="font-mono">{error.digest}</code>
16
+ </p>
17
+ ) : null}
18
+ <button
19
+ type="button"
20
+ onClick={reset}
21
+ className="mt-8 rounded-xl bg-ink px-5 py-3 text-[14px] font-medium text-surface transition-opacity hover:opacity-90"
22
+ >
23
+ Try again
24
+ </button>
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,87 @@
1
+ @import "tailwindcss";
2
+
3
+ /* Tailwind v4 scans these globs for class names. Add a line if you put
4
+ markup somewhere else. */
5
+ @source "../app/**/*.{tsx,ts,jsx,js}";
6
+ @source "../components/**/*.{tsx,ts,jsx,js}";
7
+ @source "../lib/**/*.{tsx,ts,jsx,js}";
8
+
9
+ /* The whole site re-themes from these. `--brand` is the accent on buttons
10
+ and links; the ink/surface pair carries everything else. */
11
+ :root {
12
+ --brand: #4f46e5;
13
+ --brand-ink: #ffffff;
14
+ --brand-soft: #eef2ff;
15
+ --ink: #18181b;
16
+ --ink-muted: #52525b;
17
+ --surface: #ffffff;
18
+ --surface-soft: #fafafa;
19
+ --line: #e4e4e7;
20
+ }
21
+
22
+ @media (prefers-color-scheme: dark) {
23
+ :root {
24
+ --brand: #818cf8;
25
+ --brand-ink: #1e1b4b;
26
+ --brand-soft: #1e1b4b;
27
+ --ink: #fafafa;
28
+ --ink-muted: #a1a1aa;
29
+ --surface: #09090b;
30
+ --surface-soft: #131316;
31
+ --line: #27272a;
32
+ }
33
+ }
34
+
35
+ @theme inline {
36
+ --color-brand: var(--brand);
37
+ --color-brand-ink: var(--brand-ink);
38
+ --color-brand-soft: var(--brand-soft);
39
+ --color-ink: var(--ink);
40
+ --color-ink-muted: var(--ink-muted);
41
+ --color-surface: var(--surface);
42
+ --color-surface-soft: var(--surface-soft);
43
+ --color-line: var(--line);
44
+ --font-sans:
45
+ ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
46
+ "Helvetica Neue", Arial, sans-serif;
47
+ }
48
+
49
+ html {
50
+ -webkit-text-size-adjust: 100%;
51
+ }
52
+
53
+ body {
54
+ font-family: var(--font-sans);
55
+ -webkit-font-smoothing: antialiased;
56
+ text-rendering: optimizeLegibility;
57
+ }
58
+
59
+ /* Long-form legal copy. The legal pages are plain prose, so style the
60
+ elements once here instead of putting a class on every paragraph. */
61
+ .prose-legal > * + * {
62
+ margin-top: 1rem;
63
+ }
64
+ .prose-legal h2 {
65
+ margin-top: 2.5rem;
66
+ font-size: 1.125rem;
67
+ font-weight: 600;
68
+ letter-spacing: -0.01em;
69
+ color: var(--ink);
70
+ }
71
+ .prose-legal p,
72
+ .prose-legal li {
73
+ color: var(--ink-muted);
74
+ line-height: 1.7;
75
+ }
76
+ .prose-legal ul {
77
+ margin-left: 1.125rem;
78
+ list-style: disc;
79
+ }
80
+ .prose-legal li + li {
81
+ margin-top: 0.375rem;
82
+ }
83
+ .prose-legal a {
84
+ color: var(--brand);
85
+ text-decoration: underline;
86
+ text-underline-offset: 2px;
87
+ }
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+
3
+ interface LayoutProps {
4
+ children: React.ReactNode;
5
+ }
6
+
7
+ /**
8
+ * The document shell. Chrome lives in the section layout
9
+ * (`app/(site)/layout.tsx`), which is what wraps the pages with the header
10
+ * and footer. Tailwind's compiled stylesheet is injected into <head> by the
11
+ * runtime.
12
+ *
13
+ * No <title> here on purpose: each page exports its own `metadata`, and a
14
+ * title in the layout would render first and win.
15
+ */
16
+ export default function RootLayout({ children }: LayoutProps) {
17
+ return (
18
+ <html lang="en">
19
+ <head>
20
+ <meta charSet="utf-8" />
21
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
22
+ <meta name="color-scheme" content="light dark" />
23
+ </head>
24
+ <body className="min-h-screen bg-surface text-ink">{children}</body>
25
+ </html>
26
+ );
27
+ }
@@ -0,0 +1,13 @@
1
+ import type { Robots } from "@pylonsync/react";
2
+ import { site } from "@/lib/site";
3
+
4
+ // app/robots.ts → served at /robots.txt.
5
+ const SITE = site.url || process.env.SITE_URL || "http://localhost:4321";
6
+
7
+ export default function robots(): Robots {
8
+ return {
9
+ // The API is not for crawlers; the marketing and legal pages are.
10
+ rules: { userAgent: "*", allow: "/", disallow: ["/api/"] },
11
+ sitemap: `${SITE}/sitemap.xml`,
12
+ };
13
+ }
@@ -0,0 +1,16 @@
1
+ import type { Sitemap } from "@pylonsync/react";
2
+ import { site } from "@/lib/site";
3
+
4
+ // app/sitemap.ts → served at /sitemap.xml. This is a server-only route
5
+ // module, never bundled for the browser, so reading process.env here is
6
+ // safe (page components cannot).
7
+ const SITE = site.url || process.env.SITE_URL || "http://localhost:4321";
8
+
9
+ export default function sitemap(): Sitemap {
10
+ return [
11
+ { url: `${SITE}/`, changeFrequency: "weekly", priority: 1 },
12
+ { url: `${SITE}/support`, changeFrequency: "monthly", priority: 0.5 },
13
+ { url: `${SITE}/privacy`, changeFrequency: "yearly", priority: 0.3 },
14
+ { url: `${SITE}/terms`, changeFrequency: "yearly", priority: 0.3 },
15
+ ];
16
+ }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  auth,
3
3
  buildManifest,
4
+ discoverAppRoutes,
4
5
  discoverFunctions,
5
6
  entity,
6
7
  field,
@@ -73,13 +74,20 @@ const manifest = buildManifest({
73
74
  // register each twice.
74
75
  actions: fns.actions,
75
76
  policies: [userPolicy, notePolicy, ...purchases.manifest.policies],
76
- // API only: the Expo app is the frontend.
77
- routes: [],
77
+ // The public website: landing page, support, privacy policy, and terms,
78
+ // under `app/`. One Pylon server answers the Expo app's API calls AND
79
+ // serves these pages, so the legal URLs both stores demand are live on the
80
+ // same host as soon as you deploy. Content lives in `lib/site.ts`.
81
+ routes: await discoverAppRoutes(),
78
82
  // Email/password + magic codes are on by default. Native sign-in needs
79
83
  // the app's ids on the server: PYLON_APPLE_NATIVE_CLIENT_IDS (the iOS
80
84
  // bundle id) and PYLON_GOOGLE_NATIVE_CLIENT_IDS (the iOS + Android OAuth
81
85
  // client ids). See .env.example.
82
- auth: auth(),
86
+ //
87
+ // `onDeleteAccount`: DELETE /api/auth/account runs this function before
88
+ // it removes the User row. Pylon deletes its own auth rows; the app's
89
+ // rows (Note, RcEntitlement) are deleted in functions/deleteMyData.ts.
90
+ auth: auth({ onDeleteAccount: "deleteMyData" }),
83
91
  });
84
92
 
85
93
  // The CLI runs `bun run app.ts` and reads this as the manifest.
@@ -0,0 +1,64 @@
1
+ import React from "react";
2
+ import { site } from "@/lib/site";
3
+
4
+ /**
5
+ * The frame both legal pages share: title, effective date, and the notice
6
+ * that this text is a starting point rather than reviewed legal advice.
7
+ *
8
+ * The notice is shown until `lib/site.ts` has your company details, on the
9
+ * same principle as the banner in the site layout: unfinished legal text
10
+ * should say so.
11
+ */
12
+ export function LegalPage({
13
+ title,
14
+ children,
15
+ }: {
16
+ title: string;
17
+ children: React.ReactNode;
18
+ }) {
19
+ const unfinished = !site.company || !site.supportEmail || !site.address;
20
+ return (
21
+ <article className="mx-auto max-w-3xl px-6 py-16 sm:py-20">
22
+ <h1 className="text-3xl font-semibold tracking-[-0.02em]">{title}</h1>
23
+ <p className="mt-3 text-[13px] text-ink-muted">
24
+ Effective {formatDate(site.legalEffective)}
25
+ {site.company ? ` · ${site.company}` : ""}
26
+ </p>
27
+ {unfinished ? (
28
+ <p className="mt-8 rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-[13px] leading-relaxed text-amber-900">
29
+ Draft. This text describes what the app in this template actually
30
+ does, which makes it a useful starting point, but it has not been
31
+ reviewed by a lawyer and it does not know your business. Fill in
32
+ <code className="mx-1 font-mono">lib/site.ts</code>, read every
33
+ section, and have counsel check it before you submit to a store.
34
+ </p>
35
+ ) : null}
36
+ <div className="prose-legal mt-10">{children}</div>
37
+ <p className="mt-12 border-t border-line pt-6 text-[13px] text-ink-muted">
38
+ Questions about this page?{" "}
39
+ {site.supportEmail ? (
40
+ <a href={`mailto:${site.supportEmail}`}>{site.supportEmail}</a>
41
+ ) : (
42
+ "Add a support address in lib/site.ts."
43
+ )}
44
+ </p>
45
+ </article>
46
+ );
47
+ }
48
+
49
+ /** The legal entity, or a visible placeholder while the config is blank. */
50
+ export function Company() {
51
+ return <>{site.company || "[your company]"}</>;
52
+ }
53
+
54
+ function formatDate(iso: string): string {
55
+ // Build from the parts. `new Date("2026-01-01")` parses as UTC midnight and
56
+ // renders as the day before in any negative-UTC timezone.
57
+ const [y, m, d] = iso.split("-").map(Number);
58
+ if (!y || !m || !d) return iso;
59
+ return new Date(y, m - 1, d).toLocaleDateString("en-US", {
60
+ year: "numeric",
61
+ month: "long",
62
+ day: "numeric",
63
+ });
64
+ }
@@ -9,15 +9,19 @@ import { FREE_NOTE_LIMIT, PRO_ENTITLEMENT } from "../lib/purchases";
9
9
  *
10
10
  * Returns `LIMIT_REACHED` when a free account is at the cap; the app opens
11
11
  * the paywall on that code.
12
+ *
13
+ * `auth: "guest"`: the app starts every user as a guest, and the default
14
+ * (`"user"`) rejects guest sessions with 401 before the handler runs.
12
15
  */
13
16
  export default mutation({
17
+ auth: "guest",
14
18
  args: {
15
19
  title: v.string(),
16
20
  body: v.optional(v.string()),
17
21
  },
18
22
  async handler(ctx, args: { title: string; body?: string }) {
19
23
  const userId = ctx.auth.userId;
20
- if (!userId) throw ctx.error("UNAUTHENTICATED", "sign in first");
24
+ if (!userId) throw ctx.error("UNAUTHENTICATED", "start a guest session or sign in first");
21
25
  const title = args.title.trim();
22
26
  if (!title) throw ctx.error("INVALID_ARGS", "title is required");
23
27
 
@@ -0,0 +1,38 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { ENTITLEMENT_ENTITY } from "../lib/purchases";
3
+
4
+ /**
5
+ * Deletes the caller's rows before Pylon deletes the account. Wired
6
+ * through `auth({ onDeleteAccount: "deleteMyData" })` in app.ts; the
7
+ * runtime calls it as the user with `{ userId }` and aborts the deletion
8
+ * if it throws. `internal: true` keeps it off the HTTP surface.
9
+ *
10
+ * Add every entity that stores user data here. Pylon removes the User
11
+ * row, sessions, API keys, linked accounts, and trusted devices; nothing
12
+ * else.
13
+ */
14
+ export default mutation({
15
+ internal: true,
16
+ auth: "guest",
17
+ args: { userId: v.string() },
18
+ async handler(ctx, args: { userId: string }) {
19
+ if (ctx.auth.userId !== args.userId) {
20
+ throw ctx.error("FORBIDDEN", "a user can only delete their own data");
21
+ }
22
+
23
+ const notes = await ctx.db.query("Note", { ownerId: args.userId });
24
+ for (const note of notes) {
25
+ await ctx.db.delete("Note", String(note.id));
26
+ }
27
+
28
+ // The entitlement policy denies every client delete. This is server
29
+ // code running as part of account deletion, so the unsafe surface
30
+ // (policy bypass) is the right tool.
31
+ const entitlements = await ctx.db.query(ENTITLEMENT_ENTITY, { userId: args.userId });
32
+ for (const row of entitlements) {
33
+ await ctx.db.unsafe.delete(ENTITLEMENT_ENTITY, String(row.id));
34
+ }
35
+
36
+ return { notes: notes.length, entitlements: entitlements.length };
37
+ },
38
+ });
@@ -16,7 +16,15 @@ export const FREE_NOTE_LIMIT = 10;
16
16
  // Point the dashboard webhook at https://<your-app>/api/fn/revenuecatWebhook.
17
17
  export const purchases = revenuecat({
18
18
  entitlements: [PRO_ENTITLEMENT],
19
+ // Guests can buy before they sign in (the plugin default). The server
20
+ // reads RevenueCat by the caller's own id, so a guest cannot claim an
21
+ // entitlement it did not pay for. Set `syncAuth: "user"` to require an
22
+ // account first.
23
+ syncAuth: "guest",
19
24
  });
20
25
 
26
+ /** The synced entitlement entity; `deleteMyData` purges it on account deletion. */
27
+ export const ENTITLEMENT_ENTITY = purchases.manifest.entities[0].name;
28
+
21
29
  export const { revenuecatWebhook, syncEntitlements } = purchases.handlers;
22
30
  export const { _pylonRcUpsertEntitlement } = purchases.internals;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Everything the marketing and legal pages read. This is the one file to
3
+ * edit when you rename the app, add store links, or fill in your company
4
+ * details.
5
+ *
6
+ * It is a plain module, not env vars, on purpose: SSR pages are bundled for
7
+ * hydration and the bundler rewrites `process.env.*`, so page code cannot
8
+ * read arbitrary env at render time. Server-only route modules
9
+ * (app/sitemap.ts, app/robots.ts) can, and do.
10
+ */
11
+
12
+ export const site = {
13
+ /** Shown in the header, the page titles, and the legal pages. */
14
+ name: "__APP_NAME__",
15
+ /** The hero headline. One sentence, what the app does for the person. */
16
+ tagline: "Your notes, on every device, the moment you write them",
17
+ /** The line under the headline, and the meta description. */
18
+ description:
19
+ "__APP_NAME__ keeps your notes in sync across your phone, tablet, and anything else you sign in on. It works offline and catches up when you are back.",
20
+
21
+ /**
22
+ * The public URL of this site once deployed, with no trailing slash.
23
+ * `pylon deploy` prints the host. Used for canonical links and the
24
+ * sitemap, and by the mobile app when it opens the legal pages.
25
+ */
26
+ url: "",
27
+
28
+ /** The legal entity that publishes the app. Appears on both legal pages. */
29
+ company: "",
30
+ /** Where support email and legal notices go. */
31
+ supportEmail: "",
32
+ /** Postal address. Apple asks for one on the privacy policy. */
33
+ address: "",
34
+ /** The law that governs the terms, e.g. "the State of Texas, USA". */
35
+ governingLaw: "",
36
+ /** Date the current legal text took effect, as YYYY-MM-DD. */
37
+ legalEffective: "2026-01-01",
38
+
39
+ /** Store links. Empty renders a "coming soon" state instead of a dead link. */
40
+ appStoreUrl: "",
41
+ playStoreUrl: "",
42
+
43
+ /** The three or four things worth saying on the landing page. */
44
+ features: [
45
+ {
46
+ title: "Write now, sync later",
47
+ body: "Notes save on your device first, so the app never waits on a network. They reach your other devices as soon as there is one.",
48
+ },
49
+ {
50
+ title: "Start without an account",
51
+ body: "Open the app and use it. Sign in when you want your notes on a second device, and everything you already wrote comes with you.",
52
+ },
53
+ {
54
+ title: "Yours to delete",
55
+ body: "Delete your account in Settings and your notes go with it. No support ticket, no waiting.",
56
+ },
57
+ ],
58
+
59
+ /** Free tier and subscription, kept honest with what the app enforces. */
60
+ pricing: {
61
+ freeLimit: 10,
62
+ proBlurb: "Unlimited notes and everything shipped next.",
63
+ },
64
+
65
+ faq: [
66
+ {
67
+ q: "Do I need an account?",
68
+ a: "No. The app works straight away. Signing in keeps your notes if you switch phones.",
69
+ },
70
+ {
71
+ q: "How do I cancel a subscription?",
72
+ a: "Subscriptions are billed by Apple or Google, so cancel from your App Store or Play Store account. Access lasts until the end of the period you paid for.",
73
+ },
74
+ {
75
+ q: "What happens to my notes if I delete my account?",
76
+ a: "They are deleted with it. Settings has the button, and it takes effect immediately.",
77
+ },
78
+ ],
79
+ } as const;
80
+
81
+ /**
82
+ * Config the site cannot honestly render without. The header shows a notice
83
+ * while any of these are blank, so an unfinished site cannot quietly go live
84
+ * with placeholder legal text.
85
+ */
86
+ export function missingSiteConfig(): string[] {
87
+ const missing: string[] = [];
88
+ if (!site.url) missing.push("url");
89
+ if (!site.company) missing.push("company");
90
+ if (!site.supportEmail) missing.push("supportEmail");
91
+ if (!site.address) missing.push("address");
92
+ if (!site.governingLaw) missing.push("governingLaw");
93
+ return missing;
94
+ }
@@ -13,11 +13,18 @@
13
13
  "dependencies": {
14
14
  "@pylonsync/sdk": "^__PYLON_VERSION__",
15
15
  "@pylonsync/functions": "^__PYLON_VERSION__",
16
- "@pylonsync/revenuecat": "^__PYLON_VERSION__"
16
+ "@pylonsync/react": "^__PYLON_VERSION__",
17
+ "@pylonsync/revenuecat": "^__PYLON_VERSION__",
18
+ "react": "^19.0.0",
19
+ "react-dom": "^19.0.0",
20
+ "tailwindcss": "^4.3.0",
21
+ "@tailwindcss/cli": "^4.3.0"
17
22
  },
18
23
  "devDependencies": {
19
24
  "@pylonsync/cli": "^__PYLON_VERSION__",
20
25
  "@types/node": "^22.0.0",
26
+ "@types/react": "^19.0.0",
27
+ "@types/react-dom": "^19.0.0",
21
28
  "typescript": "^5.6.0"
22
29
  }
23
30
  }
@@ -3,11 +3,15 @@
3
3
  "target": "ES2022",
4
4
  "module": "ESNext",
5
5
  "moduleResolution": "bundler",
6
+ "jsx": "react",
6
7
  "strict": true,
7
8
  "esModuleInterop": true,
8
9
  "skipLibCheck": true,
9
10
  "noEmit": true,
10
- "types": ["node"]
11
+ "lib": ["ES2022", "DOM"],
12
+ "types": ["react", "react-dom", "node"],
13
+ "baseUrl": ".",
14
+ "paths": { "@/*": ["./*"] }
11
15
  },
12
- "include": ["app.ts", "functions", "lib"]
16
+ "include": ["app.ts", "app/**/*", "components/**/*", "functions/**/*", "lib/**/*"]
13
17
  }
@@ -13,7 +13,16 @@ EXPO_PUBLIC_REVENUECAT_ANDROID_KEY=
13
13
  EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID=
14
14
  EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID=
15
15
 
16
- # Shown in Settings and required by both stores.
17
- EXPO_PUBLIC_PRIVACY_URL=https://example.com/privacy
18
- EXPO_PUBLIC_TERMS_URL=https://example.com/terms
19
- EXPO_PUBLIC_SUPPORT_EMAIL=support@example.com
16
+ # The privacy policy, terms, and help pages are served by the backend in
17
+ # apps/api, so they default to EXPO_PUBLIC_PYLON_BASE_URL + /privacy, /terms,
18
+ # and /support. Both stores require a reachable privacy policy URL.
19
+ #
20
+ # Set these only if the marketing site lives somewhere else, e.g. your own
21
+ # domain. EXPO_PUBLIC_SITE_URL moves all three at once.
22
+ # EXPO_PUBLIC_SITE_URL=https://yourapp.com
23
+ # EXPO_PUBLIC_PRIVACY_URL=
24
+ # EXPO_PUBLIC_TERMS_URL=
25
+ # EXPO_PUBLIC_SUPPORT_URL=
26
+
27
+ # Optional. Adds a "Contact support" row in Settings.
28
+ EXPO_PUBLIC_SUPPORT_EMAIL=