@pylonsync/create-pylon 0.9.1 → 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.
- package/package.json +1 -1
- 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/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
|
@@ -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'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'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,8 +74,11 @@ const manifest = buildManifest({
|
|
|
73
74
|
// register each twice.
|
|
74
75
|
actions: fns.actions,
|
|
75
76
|
policies: [userPolicy, notePolicy, ...purchases.manifest.policies],
|
|
76
|
-
//
|
|
77
|
-
|
|
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
|
|
@@ -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
|
+
}
|
|
@@ -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/
|
|
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
|
-
"
|
|
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
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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=
|
|
@@ -15,9 +15,16 @@ src/session.tsx boot + state machine
|
|
|
15
15
|
src/purchases.ts RevenueCat wrapper, safe in Expo Go
|
|
16
16
|
src/entitlements.ts usePro() from the synced RcEntitlement rows
|
|
17
17
|
src/analytics.ts funnel events; wire to your SDK in one place
|
|
18
|
+
src/links.ts privacy / terms / support URLs, served by apps/api
|
|
18
19
|
STORE.md the submission checklist
|
|
19
20
|
```
|
|
20
21
|
|
|
22
|
+
The public website (landing page, `/privacy`, `/terms`, `/support`) is served
|
|
23
|
+
by the backend in `apps/api`, on the same host as the API. The links in
|
|
24
|
+
Settings and under the paywall point there with no configuration, which is
|
|
25
|
+
what makes the store requirement satisfiable from a fresh scaffold. Edit its
|
|
26
|
+
copy in `apps/api/lib/site.ts`.
|
|
27
|
+
|
|
21
28
|
## Run
|
|
22
29
|
|
|
23
30
|
```bash
|
|
@@ -49,6 +56,8 @@ also carry Notes-specific copy or logic:
|
|
|
49
56
|
- `app/(tabs)/settings.tsx`: the delete-account confirmation.
|
|
50
57
|
- `app/(tabs)/index.tsx`: the list, `FREE_LIMIT`, and the sign-in nudge.
|
|
51
58
|
- `app.config.ts`: `name`, `slug`, `scheme`, and the icons in `assets/`.
|
|
59
|
+
- `apps/api/lib/site.ts`: the website copy, store links, and the company
|
|
60
|
+
details the privacy policy and terms need.
|
|
52
61
|
- `apps/api/functions/deleteMyData.ts`: delete every entity that stores
|
|
53
62
|
user data, or account deletion leaves rows behind.
|
|
54
63
|
- `apps/api/functions/createNote.ts`: the free-tier cap.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
The order below is the shortest path that App Review and Google Play accept
|
|
4
4
|
on the first try. Each step is one command or one dashboard page.
|
|
5
5
|
|
|
6
|
-
## 1. Backend live
|
|
6
|
+
## 1. Backend and website live
|
|
7
7
|
|
|
8
8
|
```bash
|
|
9
9
|
cd apps/api && pylon deploy
|
|
@@ -52,8 +52,12 @@ Test the purchase on TestFlight with a sandbox Apple ID before submitting.
|
|
|
52
52
|
|
|
53
53
|
Both stores require, and reviewers check:
|
|
54
54
|
|
|
55
|
-
- Privacy policy URL and terms URL
|
|
56
|
-
|
|
55
|
+
- Privacy policy URL and terms URL. The backend serves them at
|
|
56
|
+
`<your-backend>/privacy` and `<your-backend>/terms`, and the app links to
|
|
57
|
+
them from Settings and under the paywall with no configuration. Fill in
|
|
58
|
+
`apps/api/lib/site.ts` first: the text ships as a draft and a banner stays
|
|
59
|
+
on the page until you do.
|
|
60
|
+
- A support URL for App Store Connect: `<your-backend>/support`.
|
|
57
61
|
- Account deletion inside the app (Settings → Delete account). Present.
|
|
58
62
|
- Sign in with Apple when any other third-party sign-in is offered. Present.
|
|
59
63
|
- Subscription terms next to the purchase button (price, period, renewal). Present.
|
|
@@ -87,8 +91,8 @@ Without it `eas submit` asks for the app interactively.
|
|
|
87
91
|
RevenueCat, and the iOS and Android public keys are in `apps/expo/.env`.
|
|
88
92
|
- Sign-in: `PYLON_APPLE_NATIVE_CLIENT_IDS` (the bundle id) and
|
|
89
93
|
`PYLON_GOOGLE_NATIVE_CLIENT_IDS` are set on the backend.
|
|
90
|
-
- Legal: `
|
|
91
|
-
`
|
|
94
|
+
- Legal: `apps/api/lib/site.ts` is filled in, the draft banner is gone from
|
|
95
|
+
`/privacy` and `/terms`, and a lawyer has read both.
|
|
92
96
|
- Deletion: `apps/api/functions/deleteMyData.ts` removes every entity that
|
|
93
97
|
stores user data. Test it with a throwaway account.
|
|
94
98
|
- Copy: the "Replace the demo" list in `README.md` is done.
|
|
@@ -4,6 +4,7 @@ import { useRouter } from "expo-router";
|
|
|
4
4
|
import Constants from "expo-constants";
|
|
5
5
|
import { deleteAccount } from "@pylonsync/react-native";
|
|
6
6
|
import { track } from "@/analytics";
|
|
7
|
+
import { PRIVACY_URL, SUPPORT_EMAIL, SUPPORT_URL, TERMS_URL } from "@/links";
|
|
7
8
|
import { usePro } from "@/entitlements";
|
|
8
9
|
import { resetFlags } from "@/flags";
|
|
9
10
|
import { manageSubscriptionUrl, restore } from "@/purchases";
|
|
@@ -27,9 +28,8 @@ export default function Settings() {
|
|
|
27
28
|
const { pro } = usePro();
|
|
28
29
|
const [busy, setBusy] = useState<string | null>(null);
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const support = process.env.EXPO_PUBLIC_SUPPORT_EMAIL;
|
|
31
|
+
// These resolve to the site the backend serves (apps/api), so the rows
|
|
32
|
+
// always render and always lead somewhere real. See src/links.ts.
|
|
33
33
|
const version = Constants.expoConfig?.version ?? "dev";
|
|
34
34
|
|
|
35
35
|
async function restorePurchases() {
|
|
@@ -99,9 +99,12 @@ export default function Settings() {
|
|
|
99
99
|
<Spacer />
|
|
100
100
|
|
|
101
101
|
<Caption>About</Caption>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
<Row label="Help" onPress={() => void Linking.openURL(SUPPORT_URL)} />
|
|
103
|
+
<Row label="Privacy policy" onPress={() => void Linking.openURL(PRIVACY_URL)} />
|
|
104
|
+
<Row label="Terms of service" onPress={() => void Linking.openURL(TERMS_URL)} />
|
|
105
|
+
{SUPPORT_EMAIL ? (
|
|
106
|
+
<Row label="Contact support" onPress={() => void Linking.openURL(`mailto:${SUPPORT_EMAIL}`)} />
|
|
107
|
+
) : null}
|
|
105
108
|
<Row label="Version" value={version} />
|
|
106
109
|
<Spacer />
|
|
107
110
|
|