@pylonsync/create-pylon 0.3.269 → 0.3.271
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/bin/create-pylon.js +11 -9
- package/package.json +1 -1
- package/templates/b2b/app/layout.tsx +1 -1
- package/templates/b2b/app/page.tsx +2 -2
- package/templates/b2b/tsconfig.json +1 -1
- package/templates/barebones/app/page.tsx +1 -1
- package/templates/barebones/tsconfig.json +1 -1
- package/templates/chat/app/page.tsx +1 -1
- package/templates/chat/tsconfig.json +1 -1
- package/templates/consumer/app/page.tsx +1 -1
- package/templates/consumer/tsconfig.json +1 -1
- package/templates/default/.env.example +19 -0
- package/templates/{ssr → default}/README.md +20 -6
- package/templates/default/app/auth-form.tsx +218 -0
- package/templates/default/app/auth-shell.tsx +76 -0
- package/templates/default/app/company/[slug]/page.tsx +28 -0
- package/templates/default/app/compare/[slug]/page.tsx +27 -0
- package/templates/default/app/dashboard/billing/page.tsx +49 -0
- package/templates/default/app/dashboard/dashboard-client.tsx +832 -0
- package/templates/default/app/dashboard/members/page.tsx +37 -0
- package/templates/default/app/dashboard/page.tsx +64 -0
- package/templates/default/app/dashboard/projects/page.tsx +37 -0
- package/templates/default/app/dashboard/settings/page.tsx +45 -0
- package/templates/{ssr → default}/app/globals.css +14 -0
- package/templates/default/app/layout.tsx +466 -0
- package/templates/default/app/login/page.tsx +27 -0
- package/templates/default/app/onboarding/onboarding-client.tsx +261 -0
- package/templates/default/app/onboarding/page.tsx +29 -0
- package/templates/default/app/page.tsx +653 -0
- package/templates/default/app/products/[slug]/page.tsx +134 -0
- package/templates/default/app/resources/[slug]/page.tsx +28 -0
- package/templates/default/app/signup/page.tsx +24 -0
- package/templates/default/app/sitemap.ts +40 -0
- package/templates/default/app/solutions/[slug]/page.tsx +28 -0
- package/templates/{ssr → default}/app.ts +17 -2
- package/templates/default/components/dashboard-shell.tsx +150 -0
- package/templates/default/components/marketing.tsx +370 -0
- package/templates/default/functions/_pylonStripeFindActiveSubForReference.ts +3 -0
- package/templates/default/functions/_pylonStripeFindByCustomerId.ts +3 -0
- package/templates/default/functions/_pylonStripeGetCustomerHolder.ts +3 -0
- package/templates/default/functions/_pylonStripeListSubsForReference.ts +3 -0
- package/templates/default/functions/_pylonStripeOrgMembership.ts +3 -0
- package/templates/default/functions/_pylonStripeSetCustomerId.ts +3 -0
- package/templates/default/functions/_pylonStripeUpsertSubscription.ts +3 -0
- package/templates/default/functions/cancelSubscription.ts +3 -0
- package/templates/default/functions/createBillingPortalSession.ts +3 -0
- package/templates/default/functions/createCheckoutSession.ts +3 -0
- package/templates/default/functions/restoreSubscription.ts +3 -0
- package/templates/default/functions/stripeWebhook.ts +3 -0
- package/templates/default/lib/billing.ts +46 -0
- package/templates/default/lib/products.ts +122 -0
- package/templates/default/lib/site.ts +261 -0
- package/templates/{ssr → default}/package.json +2 -0
- package/templates/{ssr → default}/tsconfig.json +2 -2
- package/templates/todo/app/page.tsx +1 -1
- package/templates/todo/tsconfig.json +1 -1
- package/templates/ssr/app/auth-form.tsx +0 -142
- package/templates/ssr/app/dashboard/dashboard-client.tsx +0 -192
- package/templates/ssr/app/dashboard/page.tsx +0 -26
- package/templates/ssr/app/layout.tsx +0 -78
- package/templates/ssr/app/login/page.tsx +0 -47
- package/templates/ssr/app/page.tsx +0 -212
- package/templates/ssr/app/signup/page.tsx +0 -44
- package/templates/ssr/app/sitemap.ts +0 -27
- package/templates/ssr/functions/_keep.ts +0 -13
- /package/templates/{ssr → default}/AGENTS.md +0 -0
- /package/templates/{ssr → default}/app/error.tsx +0 -0
- /package/templates/{ssr → default}/app/not-found.tsx +0 -0
- /package/templates/{ssr → default}/app/robots.ts +0 -0
- /package/templates/{ssr → default}/components/ui/button.tsx +0 -0
- /package/templates/{ssr → default}/components/ui/card.tsx +0 -0
- /package/templates/{ssr → default}/components.json +0 -0
- /package/templates/{ssr → default}/gitignore +0 -0
- /package/templates/{ssr → default}/lib/utils.ts +0 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link } from "@pylonsync/react";
|
|
3
|
+
import type { SitePage, Comparison } from "@/lib/site";
|
|
4
|
+
|
|
5
|
+
// Reusable presentational pieces for the marketing pages (homepage +
|
|
6
|
+
// /products/[slug]). All server-rendered — no client JS. Restyle here and every
|
|
7
|
+
// marketing page follows.
|
|
8
|
+
|
|
9
|
+
// Shared container: the whole marketing site is a contained, left-aligned column.
|
|
10
|
+
export const WRAP = "mx-auto w-full max-w-5xl px-6";
|
|
11
|
+
|
|
12
|
+
export function Divider() {
|
|
13
|
+
return (
|
|
14
|
+
<div className={WRAP}>
|
|
15
|
+
<div className="border-t border-zinc-200/70" />
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function Eyebrow({ children }: { children: React.ReactNode }) {
|
|
21
|
+
return (
|
|
22
|
+
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.14em] text-brand">
|
|
23
|
+
{children}
|
|
24
|
+
</p>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function Badge({ children }: { children: React.ReactNode }) {
|
|
29
|
+
return (
|
|
30
|
+
<span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white py-1 pl-1 pr-3 text-[13px] text-zinc-600">
|
|
31
|
+
<span className="rounded-full bg-brand-soft px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-brand">
|
|
32
|
+
New
|
|
33
|
+
</span>
|
|
34
|
+
{children}
|
|
35
|
+
</span>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function SectionHead({
|
|
40
|
+
eyebrow,
|
|
41
|
+
title,
|
|
42
|
+
body,
|
|
43
|
+
arrow,
|
|
44
|
+
}: {
|
|
45
|
+
eyebrow: string;
|
|
46
|
+
title: string;
|
|
47
|
+
body: string;
|
|
48
|
+
arrow?: boolean;
|
|
49
|
+
}) {
|
|
50
|
+
return (
|
|
51
|
+
<div>
|
|
52
|
+
<Eyebrow>
|
|
53
|
+
{eyebrow}
|
|
54
|
+
{arrow ? " →" : ""}
|
|
55
|
+
</Eyebrow>
|
|
56
|
+
<h2 className="mt-4 max-w-2xl text-balance text-3xl font-semibold leading-[1.1] tracking-[-0.02em] sm:text-[2.5rem]">
|
|
57
|
+
{title}
|
|
58
|
+
</h2>
|
|
59
|
+
<p className="mt-5 max-w-xl text-[15px] leading-relaxed text-zinc-500">
|
|
60
|
+
{body}
|
|
61
|
+
</p>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function FeatureGrid({
|
|
67
|
+
items,
|
|
68
|
+
columns = 3,
|
|
69
|
+
className = "",
|
|
70
|
+
}: {
|
|
71
|
+
items: { title: string; body: string }[];
|
|
72
|
+
columns?: 2 | 3;
|
|
73
|
+
className?: string;
|
|
74
|
+
}) {
|
|
75
|
+
const cols =
|
|
76
|
+
columns === 2 ? "sm:grid-cols-2" : "sm:grid-cols-2 lg:grid-cols-3";
|
|
77
|
+
return (
|
|
78
|
+
<div className={`grid gap-x-8 gap-y-10 ${cols} ${className}`}>
|
|
79
|
+
{items.map((f) => (
|
|
80
|
+
<div key={f.title}>
|
|
81
|
+
<h3 className="text-[15px] font-medium text-brand">{f.title}</h3>
|
|
82
|
+
<p className="mt-2 text-[14px] leading-relaxed text-zinc-500">
|
|
83
|
+
{f.body}
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function PrimaryButton({
|
|
92
|
+
href,
|
|
93
|
+
children,
|
|
94
|
+
className = "",
|
|
95
|
+
}: {
|
|
96
|
+
href: string;
|
|
97
|
+
children: React.ReactNode;
|
|
98
|
+
className?: string;
|
|
99
|
+
}) {
|
|
100
|
+
return (
|
|
101
|
+
<Link
|
|
102
|
+
href={href}
|
|
103
|
+
className={`inline-flex items-center rounded-full bg-zinc-900 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-zinc-700 ${className}`}
|
|
104
|
+
>
|
|
105
|
+
{children}
|
|
106
|
+
</Link>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function GhostLink({
|
|
111
|
+
href,
|
|
112
|
+
children,
|
|
113
|
+
}: {
|
|
114
|
+
href: string;
|
|
115
|
+
children: React.ReactNode;
|
|
116
|
+
}) {
|
|
117
|
+
return (
|
|
118
|
+
<Link
|
|
119
|
+
href={href}
|
|
120
|
+
className="text-sm font-medium text-zinc-700 transition-colors hover:text-zinc-900"
|
|
121
|
+
>
|
|
122
|
+
{children}
|
|
123
|
+
</Link>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Browser-chrome frame around an image placeholder. Drop a real screenshot in
|
|
128
|
+
// place of the dashed box.
|
|
129
|
+
export function Shot({ url, label }: { url: string; label: string }) {
|
|
130
|
+
return (
|
|
131
|
+
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-[0_30px_70px_-35px_rgba(0,0,0,0.3)]">
|
|
132
|
+
<div className="flex items-center gap-1.5 border-b border-zinc-100 px-4 py-3">
|
|
133
|
+
<span className="size-2.5 rounded-full bg-zinc-200" />
|
|
134
|
+
<span className="size-2.5 rounded-full bg-zinc-200" />
|
|
135
|
+
<span className="size-2.5 rounded-full bg-zinc-200" />
|
|
136
|
+
<span className="mx-auto rounded-md bg-zinc-100 px-10 py-1 text-[11px] text-zinc-400">
|
|
137
|
+
{url}
|
|
138
|
+
</span>
|
|
139
|
+
</div>
|
|
140
|
+
<div className="grid aspect-[16/9] place-items-center bg-zinc-50">
|
|
141
|
+
<div className="flex flex-col items-center gap-2.5 text-zinc-400">
|
|
142
|
+
<span className="flex size-11 items-center justify-center rounded-xl border-2 border-dashed border-zinc-300 text-lg">
|
|
143
|
+
▦
|
|
144
|
+
</span>
|
|
145
|
+
<p className="text-sm font-medium text-zinc-500">{label}</p>
|
|
146
|
+
<p className="text-xs text-zinc-400">Replace with a screenshot</p>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Square media slot for a testimonial portrait. Renders the person's initials
|
|
154
|
+
// on a soft gradient so it looks intentional out of the box — drop in a real
|
|
155
|
+
// photo (an <img> here) when you have one.
|
|
156
|
+
export function Portrait({ name }: { name?: string }) {
|
|
157
|
+
const initials = name
|
|
158
|
+
? name
|
|
159
|
+
.split(/\s+/)
|
|
160
|
+
.map((w) => w[0])
|
|
161
|
+
.join("")
|
|
162
|
+
.slice(0, 2)
|
|
163
|
+
.toUpperCase()
|
|
164
|
+
: null;
|
|
165
|
+
return (
|
|
166
|
+
<div className="grid aspect-square w-full place-items-center overflow-hidden rounded-2xl border border-zinc-200 bg-gradient-to-br from-zinc-100 to-zinc-200/80">
|
|
167
|
+
{initials ? (
|
|
168
|
+
<span className="select-none text-5xl font-semibold tracking-tight text-zinc-400">
|
|
169
|
+
{initials}
|
|
170
|
+
</span>
|
|
171
|
+
) : (
|
|
172
|
+
<span className="flex size-12 items-center justify-center rounded-full border-2 border-dashed border-zinc-300 text-xl text-zinc-400">
|
|
173
|
+
◐
|
|
174
|
+
</span>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function Terminal() {
|
|
181
|
+
return (
|
|
182
|
+
<div className="overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950 shadow-[0_30px_70px_-35px_rgba(0,0,0,0.6)]">
|
|
183
|
+
<div className="flex items-center gap-1.5 border-b border-white/10 px-4 py-3">
|
|
184
|
+
<span className="size-2.5 rounded-full bg-white/20" />
|
|
185
|
+
<span className="size-2.5 rounded-full bg-white/20" />
|
|
186
|
+
<span className="size-2.5 rounded-full bg-white/20" />
|
|
187
|
+
<span className="ml-3 font-mono text-[11px] text-white/40">
|
|
188
|
+
acme · automation run
|
|
189
|
+
</span>
|
|
190
|
+
</div>
|
|
191
|
+
<div className="space-y-3 p-6 font-mono text-[12.5px] leading-relaxed text-zinc-300">
|
|
192
|
+
<p className="text-zinc-500">▸ Rule · when a task is moved to Done</p>
|
|
193
|
+
<p className="text-brand">
|
|
194
|
+
step 1{" "}
|
|
195
|
+
<span className="text-zinc-500">→ notify the project channel</span>
|
|
196
|
+
</p>
|
|
197
|
+
<p className="text-brand">
|
|
198
|
+
step 2{" "}
|
|
199
|
+
<span className="text-zinc-500">→ close the linked subtasks</span>
|
|
200
|
+
</p>
|
|
201
|
+
<div className="rounded-lg border-l-2 border-brand/60 bg-white/5 px-4 py-3 text-zinc-200">
|
|
202
|
+
<p className="font-semibold">Run complete</p>
|
|
203
|
+
<p className="mt-1 text-zinc-400">
|
|
204
|
+
2 steps ran in 240ms — 1 notification sent, 3 subtasks closed.
|
|
205
|
+
</p>
|
|
206
|
+
</div>
|
|
207
|
+
<p className="text-zinc-500">▸ Trigger · on every status change</p>
|
|
208
|
+
<p className="text-brand">
|
|
209
|
+
status{" "}
|
|
210
|
+
<span className="text-zinc-500">→ active · 128 runs this week</span>
|
|
211
|
+
</p>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// A generic content page (solutions / resources / company). Renders a hero, a
|
|
218
|
+
// grid of sections, a CTA, and links to its siblings.
|
|
219
|
+
export function ContentPage({
|
|
220
|
+
page,
|
|
221
|
+
siblings,
|
|
222
|
+
basePath,
|
|
223
|
+
ctaHref,
|
|
224
|
+
}: {
|
|
225
|
+
page: SitePage;
|
|
226
|
+
siblings: SitePage[];
|
|
227
|
+
basePath: string;
|
|
228
|
+
ctaHref: string;
|
|
229
|
+
}) {
|
|
230
|
+
const others = siblings.filter((s) => s.slug !== page.slug);
|
|
231
|
+
return (
|
|
232
|
+
<div className="bg-white text-zinc-900">
|
|
233
|
+
<section className={`${WRAP} pt-16 pb-16 sm:pt-20`}>
|
|
234
|
+
<Link
|
|
235
|
+
href="/"
|
|
236
|
+
className="text-[13px] text-zinc-500 transition-colors hover:text-zinc-900"
|
|
237
|
+
>
|
|
238
|
+
← Home
|
|
239
|
+
</Link>
|
|
240
|
+
<div className="mt-6">
|
|
241
|
+
<Eyebrow>{page.eyebrow}</Eyebrow>
|
|
242
|
+
</div>
|
|
243
|
+
<h1 className="mt-4 max-w-2xl text-balance text-[2.25rem] font-semibold leading-[1.05] tracking-[-0.02em] sm:text-[3rem]">
|
|
244
|
+
{page.title}
|
|
245
|
+
</h1>
|
|
246
|
+
<p className="mt-6 max-w-xl text-[17px] leading-relaxed text-zinc-500">
|
|
247
|
+
{page.summary}
|
|
248
|
+
</p>
|
|
249
|
+
<div className="mt-8">
|
|
250
|
+
<PrimaryButton href={ctaHref}>Get started</PrimaryButton>
|
|
251
|
+
</div>
|
|
252
|
+
</section>
|
|
253
|
+
|
|
254
|
+
<Divider />
|
|
255
|
+
<section className={`${WRAP} py-20`}>
|
|
256
|
+
<FeatureGrid items={page.sections} />
|
|
257
|
+
</section>
|
|
258
|
+
|
|
259
|
+
{others.length > 0 && (
|
|
260
|
+
<>
|
|
261
|
+
<Divider />
|
|
262
|
+
<section className={`${WRAP} py-16`}>
|
|
263
|
+
<Eyebrow>{page.eyebrow}</Eyebrow>
|
|
264
|
+
<div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
265
|
+
{others.map((s) => (
|
|
266
|
+
<Link
|
|
267
|
+
key={s.slug}
|
|
268
|
+
href={`${basePath}/${s.slug}`}
|
|
269
|
+
className="rounded-xl border border-zinc-200 bg-paper px-4 py-3 text-[14px] font-medium text-zinc-700 transition-colors hover:border-zinc-300 hover:bg-white hover:text-zinc-900"
|
|
270
|
+
>
|
|
271
|
+
{s.navLabel} →
|
|
272
|
+
</Link>
|
|
273
|
+
))}
|
|
274
|
+
</div>
|
|
275
|
+
</section>
|
|
276
|
+
</>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// A comparison page: hero + a feature-by-feature table + links to the other
|
|
283
|
+
// comparisons.
|
|
284
|
+
export function ComparePage({
|
|
285
|
+
cmp,
|
|
286
|
+
all,
|
|
287
|
+
ctaHref,
|
|
288
|
+
}: {
|
|
289
|
+
cmp: Comparison;
|
|
290
|
+
all: Comparison[];
|
|
291
|
+
ctaHref: string;
|
|
292
|
+
}) {
|
|
293
|
+
const others = all.filter((c) => c.slug !== cmp.slug);
|
|
294
|
+
return (
|
|
295
|
+
<div className="bg-white text-zinc-900">
|
|
296
|
+
<section className={`${WRAP} pt-16 pb-16 sm:pt-20`}>
|
|
297
|
+
<Link
|
|
298
|
+
href="/"
|
|
299
|
+
className="text-[13px] text-zinc-500 transition-colors hover:text-zinc-900"
|
|
300
|
+
>
|
|
301
|
+
← Home
|
|
302
|
+
</Link>
|
|
303
|
+
<div className="mt-6">
|
|
304
|
+
<Eyebrow>Compare</Eyebrow>
|
|
305
|
+
</div>
|
|
306
|
+
<h1 className="mt-4 max-w-2xl text-balance text-[2.25rem] font-semibold leading-[1.05] tracking-[-0.02em] sm:text-[3rem]">
|
|
307
|
+
{cmp.title}
|
|
308
|
+
</h1>
|
|
309
|
+
<p className="mt-6 max-w-xl text-[17px] leading-relaxed text-zinc-500">
|
|
310
|
+
{cmp.summary}
|
|
311
|
+
</p>
|
|
312
|
+
<div className="mt-8">
|
|
313
|
+
<PrimaryButton href={ctaHref}>Get started</PrimaryButton>
|
|
314
|
+
</div>
|
|
315
|
+
</section>
|
|
316
|
+
|
|
317
|
+
<Divider />
|
|
318
|
+
<section className={`${WRAP} py-16`}>
|
|
319
|
+
<div className="overflow-hidden rounded-2xl border border-zinc-200">
|
|
320
|
+
<table className="w-full text-[14px]">
|
|
321
|
+
<thead>
|
|
322
|
+
<tr className="border-b border-zinc-200 bg-paper text-left">
|
|
323
|
+
<th className="px-5 py-3 font-medium text-zinc-400"></th>
|
|
324
|
+
<th className="px-5 py-3 font-semibold text-zinc-900">Acme</th>
|
|
325
|
+
<th className="px-5 py-3 font-medium text-zinc-500">
|
|
326
|
+
{cmp.competitor}
|
|
327
|
+
</th>
|
|
328
|
+
</tr>
|
|
329
|
+
</thead>
|
|
330
|
+
<tbody>
|
|
331
|
+
{cmp.rows.map((r) => (
|
|
332
|
+
<tr
|
|
333
|
+
key={r.dim}
|
|
334
|
+
className="border-b border-zinc-100 last:border-0"
|
|
335
|
+
>
|
|
336
|
+
<td className="px-5 py-3.5 text-zinc-600">{r.dim}</td>
|
|
337
|
+
<td className="px-5 py-3.5 font-medium text-zinc-900">
|
|
338
|
+
<span className="mr-2 text-brand">✓</span>
|
|
339
|
+
{r.acme}
|
|
340
|
+
</td>
|
|
341
|
+
<td className="px-5 py-3.5 text-zinc-500">{r.them}</td>
|
|
342
|
+
</tr>
|
|
343
|
+
))}
|
|
344
|
+
</tbody>
|
|
345
|
+
</table>
|
|
346
|
+
</div>
|
|
347
|
+
</section>
|
|
348
|
+
|
|
349
|
+
{others.length > 0 && (
|
|
350
|
+
<>
|
|
351
|
+
<Divider />
|
|
352
|
+
<section className={`${WRAP} py-16`}>
|
|
353
|
+
<Eyebrow>More comparisons</Eyebrow>
|
|
354
|
+
<div className="mt-6 grid gap-3 sm:grid-cols-3">
|
|
355
|
+
{others.map((c) => (
|
|
356
|
+
<Link
|
|
357
|
+
key={c.slug}
|
|
358
|
+
href={`/compare/${c.slug}`}
|
|
359
|
+
className="rounded-xl border border-zinc-200 bg-paper px-4 py-3 text-[14px] font-medium text-zinc-700 transition-colors hover:border-zinc-300 hover:bg-white hover:text-zinc-900"
|
|
360
|
+
>
|
|
361
|
+
{c.navLabel} →
|
|
362
|
+
</Link>
|
|
363
|
+
))}
|
|
364
|
+
</div>
|
|
365
|
+
</section>
|
|
366
|
+
</>
|
|
367
|
+
)}
|
|
368
|
+
</div>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { stripe } from "@pylonsync/stripe";
|
|
2
|
+
|
|
3
|
+
// Per-workspace (org) billing. The active org IS the billing reference: its
|
|
4
|
+
// subscription decides the plan, and only owners/admins can change it (the
|
|
5
|
+
// plugin enforces that for `referenceType: "org"`). The Stripe customer is
|
|
6
|
+
// created on first checkout and stored on `Org.stripeCustomerId`.
|
|
7
|
+
//
|
|
8
|
+
// Configure with env vars (nothing is hard-coded so test/live swap cleanly):
|
|
9
|
+
// STRIPE_SECRET_KEY sk_test_… / sk_live_… (required to charge)
|
|
10
|
+
// STRIPE_WEBHOOK_SECRET whsec_… (required for the webhook)
|
|
11
|
+
// STRIPE_PRICE_PRO price_… (the "pro" monthly price)
|
|
12
|
+
// Until STRIPE_SECRET_KEY is set the handlers return STRIPE_NOT_CONFIGURED and
|
|
13
|
+
// the Billing page shows a "connect Stripe" state — the app still boots + runs.
|
|
14
|
+
export const billing = stripe({
|
|
15
|
+
referenceType: "org",
|
|
16
|
+
plans: [
|
|
17
|
+
{
|
|
18
|
+
name: "pro",
|
|
19
|
+
priceId: process.env.STRIPE_PRICE_PRO ?? "",
|
|
20
|
+
// App-defined entitlement limits, stored on the subscription row so
|
|
21
|
+
// quota checks don't need the plan list. Tune to whatever you meter.
|
|
22
|
+
limits: { projects: 100, seats: 25 },
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Pylon's file-based function loader needs one default export per function
|
|
28
|
+
// file, so each handler gets a one-line wrapper under functions/. Re-export the
|
|
29
|
+
// public actions + the plugin-internal `_pylonStripe*` handlers (called via
|
|
30
|
+
// ctx.runQuery / ctx.runMutation) so those wrappers have something to import.
|
|
31
|
+
const h = billing.handlers as Record<string, unknown>;
|
|
32
|
+
|
|
33
|
+
export const createCheckoutSession = h.createCheckoutSession;
|
|
34
|
+
export const createBillingPortalSession = h.createBillingPortalSession;
|
|
35
|
+
export const cancelSubscription = h.cancelSubscription;
|
|
36
|
+
export const restoreSubscription = h.restoreSubscription;
|
|
37
|
+
export const stripeWebhook = h.stripeWebhook;
|
|
38
|
+
|
|
39
|
+
export const _pylonStripeListSubsForReference = h._pylonStripeListSubsForReference;
|
|
40
|
+
export const _pylonStripeFindActiveSubForReference =
|
|
41
|
+
h._pylonStripeFindActiveSubForReference;
|
|
42
|
+
export const _pylonStripeGetCustomerHolder = h._pylonStripeGetCustomerHolder;
|
|
43
|
+
export const _pylonStripeFindByCustomerId = h._pylonStripeFindByCustomerId;
|
|
44
|
+
export const _pylonStripeOrgMembership = h._pylonStripeOrgMembership;
|
|
45
|
+
export const _pylonStripeSetCustomerId = h._pylonStripeSetCustomerId;
|
|
46
|
+
export const _pylonStripeUpsertSubscription = h._pylonStripeUpsertSubscription;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Single source of truth for the marketing "products". The nav dropdown, the
|
|
2
|
+
// homepage feature sections, and the /products/[slug] pages all read from here,
|
|
3
|
+
// so a product is defined once. Add an entry and it shows up everywhere.
|
|
4
|
+
// Fictional demo copy — rename these to your real product modules.
|
|
5
|
+
|
|
6
|
+
export type ProductFeature = { title: string; body: string };
|
|
7
|
+
|
|
8
|
+
export type Product = {
|
|
9
|
+
slug: string;
|
|
10
|
+
icon: string;
|
|
11
|
+
title: string; // nav label + page <h1> subject
|
|
12
|
+
tagline: string; // one-line blurb for the nav dropdown
|
|
13
|
+
eyebrow: string; // section/page eyebrow
|
|
14
|
+
headline: string; // page hero headline
|
|
15
|
+
summary: string; // page hero paragraph
|
|
16
|
+
features: ProductFeature[];
|
|
17
|
+
mockupUrl: string; // fake browser URL in the screenshot frame
|
|
18
|
+
mockupLabel: string; // placeholder label inside the frame
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const PRODUCTS: Product[] = [
|
|
22
|
+
{
|
|
23
|
+
slug: "projects",
|
|
24
|
+
icon: "▤",
|
|
25
|
+
title: "Projects",
|
|
26
|
+
tagline: "Plan and track every initiative.",
|
|
27
|
+
eyebrow: "Projects",
|
|
28
|
+
headline: "Plan every project in one place.",
|
|
29
|
+
summary:
|
|
30
|
+
"Give your team one place to plan the work, see what is in flight, and keep every project moving toward done.",
|
|
31
|
+
mockupUrl: "acme.app/projects",
|
|
32
|
+
mockupLabel: "Projects board",
|
|
33
|
+
features: [
|
|
34
|
+
{ title: "Flexible views", body: "See the work as a board, a list, or a timeline — whatever fits the team." },
|
|
35
|
+
{ title: "Milestones", body: "Group work into milestones so everyone knows what ships next." },
|
|
36
|
+
{ title: "Dependencies", body: "Link related work so blockers surface before they bite." },
|
|
37
|
+
{ title: "Custom fields", body: "Track the details that matter to your team, your way." },
|
|
38
|
+
{ title: "Templates", body: "Start new projects from a template instead of a blank page." },
|
|
39
|
+
{ title: "Saved filters", body: "Slice the work by owner, status, or label in a single click." },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
slug: "tasks",
|
|
44
|
+
icon: "✓",
|
|
45
|
+
title: "Tasks",
|
|
46
|
+
tagline: "Assign, prioritize, and finish work.",
|
|
47
|
+
eyebrow: "Tasks",
|
|
48
|
+
headline: "Turn plans into finished work.",
|
|
49
|
+
summary:
|
|
50
|
+
"Break projects into tasks, assign owners, and watch progress update in real time across every screen.",
|
|
51
|
+
mockupUrl: "acme.app/tasks",
|
|
52
|
+
mockupLabel: "Task list",
|
|
53
|
+
features: [
|
|
54
|
+
{ title: "Assignees and due dates", body: "Every task has a clear owner and a clear deadline." },
|
|
55
|
+
{ title: "Subtasks", body: "Break big tasks into small, checkable steps." },
|
|
56
|
+
{ title: "Priorities", body: "Sort by what matters most so the right work happens first." },
|
|
57
|
+
{ title: "Comments", body: "Discuss the work where it lives, with full context attached." },
|
|
58
|
+
{ title: "My work", body: "A personal view of everything on your plate, across projects." },
|
|
59
|
+
{ title: "Recurring tasks", body: "Set it once and the task comes back when it should." },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
slug: "docs",
|
|
64
|
+
icon: "≡",
|
|
65
|
+
title: "Docs",
|
|
66
|
+
tagline: "Write and share team knowledge.",
|
|
67
|
+
eyebrow: "Docs",
|
|
68
|
+
headline: "Keep what your team knows in one place.",
|
|
69
|
+
summary:
|
|
70
|
+
"Write docs, notes, and specs alongside the work, so the context never lives in just one person's head.",
|
|
71
|
+
mockupUrl: "acme.app/docs",
|
|
72
|
+
mockupLabel: "Doc editor",
|
|
73
|
+
features: [
|
|
74
|
+
{ title: "Rich editor", body: "Headings, checklists, tables, and embeds in a clean editor." },
|
|
75
|
+
{ title: "Linked to work", body: "Connect a doc to the project or task it describes." },
|
|
76
|
+
{ title: "Real-time co-editing", body: "Write together, with changes syncing as you type." },
|
|
77
|
+
{ title: "Version history", body: "Roll back to any earlier version in a click." },
|
|
78
|
+
{ title: "Templates", body: "Spin up specs, briefs, and notes from reusable templates." },
|
|
79
|
+
{ title: "Instant search", body: "Find any doc by title or content in milliseconds." },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
slug: "automations",
|
|
84
|
+
icon: "⟳",
|
|
85
|
+
title: "Automations",
|
|
86
|
+
tagline: "Automate the busywork.",
|
|
87
|
+
eyebrow: "Automations",
|
|
88
|
+
headline: "Let the routine work run itself.",
|
|
89
|
+
summary:
|
|
90
|
+
"Build simple rules that move work forward automatically, so your team spends its time on what actually matters.",
|
|
91
|
+
mockupUrl: "acme.app/automations",
|
|
92
|
+
mockupLabel: "Automation builder",
|
|
93
|
+
features: [
|
|
94
|
+
{ title: "Rules", body: "When this happens, do that — no code required." },
|
|
95
|
+
{ title: "Scheduled runs", body: "Kick off routine work on a schedule you set." },
|
|
96
|
+
{ title: "Webhooks", body: "Trigger automations from anything that can send a request." },
|
|
97
|
+
{ title: "Run history", body: "See exactly what ran, when, and why." },
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
slug: "analytics",
|
|
102
|
+
icon: "◔",
|
|
103
|
+
title: "Analytics",
|
|
104
|
+
tagline: "Measure what actually matters.",
|
|
105
|
+
eyebrow: "Analytics",
|
|
106
|
+
headline: "See how the work is really going.",
|
|
107
|
+
summary:
|
|
108
|
+
"Track throughput, cycle time, and progress across projects, so you can spot what is stuck before it slips.",
|
|
109
|
+
mockupUrl: "acme.app/analytics",
|
|
110
|
+
mockupLabel: "Analytics dashboard",
|
|
111
|
+
features: [
|
|
112
|
+
{ title: "Dashboards", body: "Build views that answer the questions your team asks most." },
|
|
113
|
+
{ title: "Cycle time", body: "See how long work takes from start to done." },
|
|
114
|
+
{ title: "Throughput", body: "Track how much ships each week, by team or project." },
|
|
115
|
+
{ title: "Exports", body: "Send any view to CSV or your warehouse." },
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
export function productBySlug(slug: string): Product | undefined {
|
|
121
|
+
return PRODUCTS.find((p) => p.slug === slug);
|
|
122
|
+
}
|