@pylonsync/create-pylon 0.3.275 → 0.3.276

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 (31) hide show
  1. package/bin/create-pylon.js +1 -1
  2. package/package.json +1 -1
  3. package/templates/agency/app/dashboard/dashboard-client.tsx +1213 -59
  4. package/templates/agency/app/layout.tsx +1 -1
  5. package/templates/agency/app/page.tsx +72 -30
  6. package/templates/agency/app/seeder.tsx +26 -0
  7. package/templates/agency/app/work/[slug]/page.tsx +182 -0
  8. package/templates/agency/app/work/page.tsx +83 -0
  9. package/templates/agency/app.ts +168 -19
  10. package/templates/agency/components/marketing.tsx +39 -0
  11. package/templates/agency/functions/clientsForOwner.ts +27 -0
  12. package/templates/agency/functions/deleteClient.ts +27 -0
  13. package/templates/agency/functions/deleteInvoice.ts +19 -0
  14. package/templates/agency/functions/deleteProject.ts +20 -0
  15. package/templates/agency/functions/invoicesForOwner.ts +27 -0
  16. package/templates/agency/functions/seedProjects.ts +41 -0
  17. package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
  18. package/templates/agency/functions/setInvoiceStatus.ts +27 -0
  19. package/templates/agency/functions/setProjectFlags.ts +35 -0
  20. package/templates/agency/functions/upsertClient.ts +73 -0
  21. package/templates/agency/functions/upsertInvoice.ts +113 -0
  22. package/templates/agency/functions/upsertProject.ts +97 -0
  23. package/templates/agency/lib/agency.ts +165 -3
  24. package/templates/agency/lib/invoice-pdf.tsx +174 -0
  25. package/templates/agency/lib/site.config.ts +180 -1
  26. package/templates/agency/package.json +2 -1
  27. package/templates/ai-chat/app/chat-client.tsx +354 -41
  28. package/templates/ai-chat/functions/deleteConversation.ts +33 -0
  29. package/templates/ai-studio/app/studio-client.tsx +172 -29
  30. package/templates/ai-studio/app.ts +7 -7
  31. package/templates/ai-studio/lib/studio.ts +5 -5
@@ -70,7 +70,7 @@ export default function RootLayout({ children, url, auth }: LayoutProps) {
70
70
  </span>
71
71
  </Link>
72
72
  <nav className="flex items-center gap-1 sm:gap-2">
73
- <a href="/#work" className="hidden rounded-full px-3 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:text-zinc-900 sm:inline-flex">
73
+ <a href="/work" className="hidden rounded-full px-3 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:text-zinc-900 sm:inline-flex">
74
74
  Work
75
75
  </a>
76
76
  <a href="/#services" className="hidden rounded-full px-3 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:text-zinc-900 sm:inline-flex">
@@ -1,15 +1,18 @@
1
- import React from "react";
2
- import { type Metadata } from "@pylonsync/react";
1
+ import React, { Suspense, use } from "react";
2
+ import { Link, type Metadata, type PageProps, type ServerData } from "@pylonsync/react";
3
3
  import {
4
4
  WRAP,
5
5
  Eyebrow,
6
6
  Divider,
7
7
  SectionHead,
8
8
  ImagePlaceholder,
9
+ ProjectCard,
9
10
  initials,
10
11
  } from "@/components/marketing";
11
12
  import { LiveSlots, ContactForm } from "./contact-form";
13
+ import { SeedProjects } from "./seeder";
12
14
  import { siteConfig } from "@/lib/site.config";
15
+ import { slugify, viewFromRow, type ProjectRow, type ProjectView } from "@/lib/agency";
13
16
 
14
17
  export const metadata: Metadata = {
15
18
  title: siteConfig.seo.title,
@@ -17,12 +20,46 @@ export const metadata: Metadata = {
17
20
  openGraph: { title: siteConfig.seo.title, description: siteConfig.seo.description, type: "website" },
18
21
  };
19
22
 
23
+ // The homepage "Selected work" grid reads the live Project portfolio on the
24
+ // server (the `selected` + `published` ones, ordered), so curating it in the
25
+ // dashboard re-curates the homepage. Before the portfolio is seeded, it falls
26
+ // back to the config case studies so the section is never empty on first paint.
27
+ function selectedFromConfig(): ProjectView[] {
28
+ return siteConfig.work.items
29
+ .filter((c) => c.selected)
30
+ .map((c) => ({
31
+ slug: c.slug || slugify(c.title),
32
+ title: c.title,
33
+ client: c.client,
34
+ summary: c.summary,
35
+ year: c.year ?? null,
36
+ tags: c.tags,
37
+ }));
38
+ }
39
+
40
+ function SelectedWork({ serverData }: { serverData: ServerData }) {
41
+ const rows = use(serverData.list<ProjectRow>("Project"));
42
+ const fromDb = rows
43
+ .filter((p) => p.selected && p.published)
44
+ .sort((a, b) => a.order - b.order || (a.createdAt < b.createdAt ? -1 : 1))
45
+ .map(viewFromRow);
46
+ const projects = fromDb.length > 0 ? fromDb : selectedFromConfig();
47
+
48
+ return (
49
+ <div className="mt-10 grid gap-6 sm:grid-cols-2">
50
+ {projects.map((p) => (
51
+ <ProjectCard key={p.slug} p={p} />
52
+ ))}
53
+ </div>
54
+ );
55
+ }
56
+
20
57
  // `app/page.tsx` → `/`. Server-rendered studio site. Hero, services, work,
21
58
  // process, team, and testimonials are static server HTML (SEO + first paint);
22
59
  // the live "slots open" pill and the contact form (#contact) are client islands
23
- // driven by the public Capacity row. All copy comes from siteConfig. Doesn't
24
- // read `auth`, so the public page stays cacheable.
25
- export default function LandingPage() {
60
+ // driven by the public Capacity row. The "Selected work" grid reads the Project
61
+ // portfolio server-side. All other copy comes from siteConfig.
62
+ export default function LandingPage({ serverData }: PageProps) {
26
63
  const { hero, logos, services, work, process, team, testimonials, contact } = siteConfig;
27
64
 
28
65
  return (
@@ -97,32 +134,34 @@ export default function LandingPage() {
97
134
  {/* ============================== WORK ============================= */}
98
135
  <Divider />
99
136
  <section id="work" className={`${WRAP} py-16`}>
100
- <SectionHead eyebrow={work.eyebrow} title={work.headline} />
101
- <div className="mt-10 grid gap-6 sm:grid-cols-2">
102
- {work.items.map((c) => (
103
- <div key={c.title} className="group">
104
- {/* Case-study image drop in a real project screenshot. */}
105
- <ImagePlaceholder
106
- shape="landscape"
107
- title={`${c.title} — project shot`}
108
- hint="Swap for an <img> per case study"
109
- />
110
- <div className="mt-4 flex items-baseline justify-between gap-3">
111
- <h3 className="text-[16px] font-semibold text-zinc-900">{c.title}</h3>
112
- <span className="shrink-0 font-mono text-[11px] uppercase tracking-wide text-zinc-400">
113
- {c.client}
114
- </span>
115
- </div>
116
- <p className="mt-1.5 text-[14px] leading-relaxed text-zinc-500">{c.summary}</p>
117
- <div className="mt-3 flex flex-wrap gap-1.5">
118
- {c.tags.map((t) => (
119
- <span key={t} className="rounded-full bg-zinc-100 px-2.5 py-0.5 text-[11px] font-medium text-zinc-600">
120
- {t}
121
- </span>
122
- ))}
123
- </div>
137
+ <div className="flex items-end justify-between gap-4">
138
+ <SectionHead eyebrow={work.eyebrow} title={work.headline} />
139
+ <Link
140
+ href="/work"
141
+ className="hidden shrink-0 text-[13.5px] font-medium text-zinc-600 transition-colors hover:text-zinc-900 sm:inline-flex"
142
+ >
143
+ All work →
144
+ </Link>
145
+ </div>
146
+ <Suspense
147
+ fallback={
148
+ <div className="mt-10 grid gap-6 sm:grid-cols-2">
149
+ {[0, 1].map((i) => (
150
+ <div key={i}>
151
+ <div className="aspect-[4/3] animate-pulse rounded-2xl bg-zinc-100" />
152
+ <div className="mt-4 h-4 w-1/3 animate-pulse rounded bg-zinc-100" />
153
+ <div className="mt-2 h-3 w-3/4 animate-pulse rounded bg-zinc-100" />
154
+ </div>
155
+ ))}
124
156
  </div>
125
- ))}
157
+ }
158
+ >
159
+ <SelectedWork serverData={serverData} />
160
+ </Suspense>
161
+ <div className="mt-8 sm:hidden">
162
+ <Link href="/work" className="text-[14px] font-medium text-brand">
163
+ See all work →
164
+ </Link>
126
165
  </div>
127
166
  </section>
128
167
 
@@ -202,6 +241,9 @@ export default function LandingPage() {
202
241
  <ContactForm />
203
242
  </div>
204
243
  </section>
244
+
245
+ {/* Seeds the public portfolio on first visit (idempotent, zero UI). */}
246
+ <SeedProjects />
205
247
  </div>
206
248
  );
207
249
  }
@@ -0,0 +1,26 @@
1
+ "use client";
2
+
3
+ import React, { useEffect } from "react";
4
+ import { callFn } from "@pylonsync/react";
5
+ import { EnsureGuest } from "@pylonsync/client";
6
+
7
+ // A zero-UI client island that seeds the public Project portfolio on first
8
+ // visit (idempotent server-side — a no-op once any project exists). Drop it on
9
+ // any public page so /work and the case-study pages aren't empty even if the
10
+ // visitor never hit the homepage. Wrapped in <EnsureGuest> so a session exists
11
+ // for the call; seedProjects is a public mutation, so an anonymous guest can run
12
+ // it (it only ever writes the config's marketing copy — no PII).
13
+ export function SeedProjects() {
14
+ return (
15
+ <EnsureGuest fallback={null}>
16
+ <Seed />
17
+ </EnsureGuest>
18
+ );
19
+ }
20
+
21
+ function Seed() {
22
+ useEffect(() => {
23
+ void callFn("seedProjects", {});
24
+ }, []);
25
+ return null;
26
+ }
@@ -0,0 +1,182 @@
1
+ import React, { Suspense, use } from "react";
2
+ import {
3
+ Link,
4
+ type GenerateMetadata,
5
+ type Metadata,
6
+ type PageProps,
7
+ type ServerData,
8
+ type SsrResponse,
9
+ } from "@pylonsync/react";
10
+ import { ImagePlaceholder } from "@/components/marketing";
11
+ import { SeedProjects } from "../../seeder";
12
+ import { siteConfig } from "@/lib/site.config";
13
+ import { slugify, viewFromRow, type ProjectRow, type ProjectView } from "@/lib/agency";
14
+
15
+ // Resolve a case study from the URL slug. Prefer the live Project row; if the
16
+ // row exists but is a draft (`published === false`) it's NOT public → treat as
17
+ // missing. Only when there's NO row at all (the portfolio hasn't been seeded
18
+ // yet) do we fall back to the matching config case study, so deep links work on
19
+ // a fresh install.
20
+ async function resolveProject(
21
+ serverData: ServerData,
22
+ slug: string,
23
+ ): Promise<ProjectView | null> {
24
+ const row = await serverData.lookup<ProjectRow>("Project", "slug", slug);
25
+ if (row) return row.published ? viewFromRow(row) : null;
26
+
27
+ const cfg = siteConfig.work.items.find((c) => (c.slug || slugify(c.title)) === slug);
28
+ if (!cfg) return null;
29
+ return {
30
+ slug: cfg.slug || slugify(cfg.title),
31
+ title: cfg.title,
32
+ client: cfg.client,
33
+ summary: cfg.summary,
34
+ year: cfg.year ?? null,
35
+ tags: cfg.tags,
36
+ challenge: cfg.challenge ?? null,
37
+ approach: cfg.approach ?? null,
38
+ outcome: cfg.outcome ?? null,
39
+ liveUrl: cfg.liveUrl ?? null,
40
+ };
41
+ }
42
+
43
+ export const generateMetadata: GenerateMetadata = async ({
44
+ params,
45
+ serverData,
46
+ }): Promise<Metadata> => {
47
+ const p = await resolveProject(serverData, params.slug);
48
+ if (!p) return { title: `Case study not found — ${siteConfig.brand.name}`, robots: "noindex" };
49
+ return {
50
+ title: `${p.title} — ${siteConfig.brand.name}`,
51
+ description: p.summary,
52
+ openGraph: { title: `${p.title} — ${siteConfig.brand.name}`, description: p.summary, type: "article" },
53
+ };
54
+ };
55
+
56
+ const WRAP_NARROW = "mx-auto w-full max-w-3xl px-6";
57
+
58
+ function CaseStudy({
59
+ serverData,
60
+ response,
61
+ slug,
62
+ }: {
63
+ serverData: ServerData;
64
+ response: SsrResponse;
65
+ slug: string;
66
+ }) {
67
+ const p = use(resolveProject(serverData, slug));
68
+
69
+ if (!p) {
70
+ response.setStatus(404);
71
+ return (
72
+ <div className={`${WRAP_NARROW} py-24 text-center`}>
73
+ <p className="text-[15px] font-medium text-zinc-900">That case study doesn&apos;t exist.</p>
74
+ <Link href="/work" className="mt-2 inline-block text-[14px] font-medium text-brand">
75
+ ← Back to all work
76
+ </Link>
77
+ </div>
78
+ );
79
+ }
80
+
81
+ return (
82
+ <article className={`${WRAP_NARROW} py-14`}>
83
+ <Link href="/work" className="text-[13.5px] font-medium text-zinc-500 transition-colors hover:text-zinc-900">
84
+ ← All work
85
+ </Link>
86
+
87
+ <header className="mt-6">
88
+ <p className="font-mono text-[11px] uppercase tracking-[0.16em] text-brand">
89
+ {p.client}
90
+ {p.year ? ` · ${p.year}` : ""}
91
+ </p>
92
+ <h1 className="mt-3 text-balance text-[2rem] font-semibold leading-[1.08] tracking-[-0.02em] sm:text-[2.5rem]">
93
+ {p.title}
94
+ </h1>
95
+ <p className="mt-4 max-w-2xl text-[17px] leading-relaxed text-zinc-500">{p.summary}</p>
96
+ {p.tags.length > 0 ? (
97
+ <div className="mt-5 flex flex-wrap gap-1.5">
98
+ {p.tags.map((t) => (
99
+ <span key={t} className="rounded-full bg-zinc-100 px-2.5 py-0.5 text-[11px] font-medium text-zinc-600">
100
+ {t}
101
+ </span>
102
+ ))}
103
+ </div>
104
+ ) : null}
105
+ {p.liveUrl ? (
106
+ <a
107
+ href={p.liveUrl}
108
+ target="_blank"
109
+ rel="noopener noreferrer"
110
+ className="mt-6 inline-flex items-center rounded-full border border-zinc-300 px-4 py-2 text-[13.5px] font-medium text-zinc-700 transition-colors hover:border-zinc-400 hover:text-zinc-900"
111
+ >
112
+ Visit live site ↗
113
+ </a>
114
+ ) : null}
115
+ </header>
116
+
117
+ {/* Hero shot — drop in a real project image. */}
118
+ <div className="mt-10">
119
+ <ImagePlaceholder shape="landscape" title={`${p.title} — hero shot`} hint="Swap for an <img> in app/work/[slug]/page.tsx" />
120
+ </div>
121
+
122
+ <div className="mt-12 space-y-10">
123
+ <CaseSection label="The challenge" body={p.challenge} />
124
+ <CaseSection label="Our approach" body={p.approach} />
125
+ <CaseSection label="The outcome" body={p.outcome} />
126
+ {!p.challenge && !p.approach && !p.outcome ? (
127
+ <p className="text-[15px] leading-relaxed text-zinc-500">
128
+ A full write-up is on the way. In the meantime, {p.summary.toLowerCase()}
129
+ </p>
130
+ ) : null}
131
+ </div>
132
+
133
+ {/* Contact CTA */}
134
+ <div className="mt-14 rounded-2xl border border-zinc-200 bg-paper p-8 text-center">
135
+ <h2 className="text-[18px] font-semibold tracking-tight text-zinc-900">Have something like this in mind?</h2>
136
+ <p className="mx-auto mt-2 max-w-md text-[14px] leading-relaxed text-zinc-500">
137
+ We take on a few projects at a time. Tell us what you&apos;re building.
138
+ </p>
139
+ <Link
140
+ href="/#contact"
141
+ className="mt-5 inline-flex items-center rounded-full bg-brand px-5 py-2.5 text-[14px] font-medium text-white transition-opacity hover:opacity-90"
142
+ >
143
+ Start a project
144
+ </Link>
145
+ </div>
146
+
147
+ <SeedProjects />
148
+ </article>
149
+ );
150
+ }
151
+
152
+ function CaseSection({ label, body }: { label: string; body?: string | null }) {
153
+ if (!body) return null;
154
+ return (
155
+ <section>
156
+ <h2 className="font-mono text-[11px] uppercase tracking-[0.16em] text-zinc-400">{label}</h2>
157
+ <p className="mt-3 whitespace-pre-wrap text-[16px] leading-relaxed text-zinc-700">{body}</p>
158
+ </section>
159
+ );
160
+ }
161
+
162
+ // `app/work/[slug]/page.tsx` → `/work/:slug`. The case-study detail. Suspends on
163
+ // the project lookup; renders a 404 (with a real status) when the slug is
164
+ // unknown or the project is a draft.
165
+ export default function CaseStudyPage({ params, serverData, response }: PageProps) {
166
+ return (
167
+ <div className="bg-white text-zinc-900">
168
+ <Suspense
169
+ fallback={
170
+ <div className={`${WRAP_NARROW} py-14`}>
171
+ <div className="h-4 w-20 animate-pulse rounded bg-zinc-100" />
172
+ <div className="mt-6 h-10 w-2/3 animate-pulse rounded bg-zinc-100" />
173
+ <div className="mt-4 h-4 w-full animate-pulse rounded bg-zinc-100" />
174
+ <div className="mt-10 aspect-[4/3] animate-pulse rounded-2xl bg-zinc-100" />
175
+ </div>
176
+ }
177
+ >
178
+ <CaseStudy serverData={serverData} response={response} slug={params.slug} />
179
+ </Suspense>
180
+ </div>
181
+ );
182
+ }
@@ -0,0 +1,83 @@
1
+ import React, { Suspense, use } from "react";
2
+ import { Link, type Metadata, type PageProps, type ServerData } from "@pylonsync/react";
3
+ import { WRAP, SectionHead, ProjectCard } from "@/components/marketing";
4
+ import { SeedProjects } from "../seeder";
5
+ import { siteConfig } from "@/lib/site.config";
6
+ import { slugify, viewFromRow, type ProjectRow, type ProjectView } from "@/lib/agency";
7
+
8
+ export const metadata: Metadata = {
9
+ title: `Work — ${siteConfig.brand.name}`,
10
+ description: `Case studies from ${siteConfig.brand.name}: ${siteConfig.work.headline}`,
11
+ openGraph: { title: `Work — ${siteConfig.brand.name}`, type: "website" },
12
+ };
13
+
14
+ // `app/work/page.tsx` → `/work`. The full portfolio: every PUBLISHED project,
15
+ // server-rendered for SEO, each linking to its case study. Reads the Project
16
+ // entity via serverData; falls back to the config case studies before the
17
+ // portfolio is seeded so the page is never empty.
18
+ function allFromConfig(): ProjectView[] {
19
+ return siteConfig.work.items.map((c) => ({
20
+ slug: c.slug || slugify(c.title),
21
+ title: c.title,
22
+ client: c.client,
23
+ summary: c.summary,
24
+ year: c.year ?? null,
25
+ tags: c.tags,
26
+ }));
27
+ }
28
+
29
+ function WorkGrid({ serverData }: { serverData: ServerData }) {
30
+ const rows = use(serverData.list<ProjectRow>("Project"));
31
+ const fromDb = rows
32
+ .filter((p) => p.published)
33
+ .sort((a, b) => a.order - b.order || (a.createdAt < b.createdAt ? -1 : 1))
34
+ .map(viewFromRow);
35
+ const projects = fromDb.length > 0 ? fromDb : allFromConfig();
36
+
37
+ if (projects.length === 0) {
38
+ return <p className="mt-10 text-[15px] text-zinc-500">No published work yet.</p>;
39
+ }
40
+ return (
41
+ <div className="mt-10 grid gap-x-6 gap-y-10 sm:grid-cols-2">
42
+ {projects.map((p) => (
43
+ <ProjectCard key={p.slug} p={p} />
44
+ ))}
45
+ </div>
46
+ );
47
+ }
48
+
49
+ export default function WorkIndexPage({ serverData }: PageProps) {
50
+ return (
51
+ <div className="bg-white text-zinc-900">
52
+ <section className={`${WRAP} py-16`}>
53
+ <SectionHead
54
+ eyebrow="Work"
55
+ title={siteConfig.work.headline}
56
+ body="A fuller look at what we've shipped — each one a short case study."
57
+ />
58
+ <Suspense
59
+ fallback={
60
+ <div className="mt-10 grid gap-x-6 gap-y-10 sm:grid-cols-2">
61
+ {[0, 1, 2, 3].map((i) => (
62
+ <div key={i}>
63
+ <div className="aspect-[4/3] animate-pulse rounded-2xl bg-zinc-100" />
64
+ <div className="mt-4 h-4 w-1/3 animate-pulse rounded bg-zinc-100" />
65
+ </div>
66
+ ))}
67
+ </div>
68
+ }
69
+ >
70
+ <WorkGrid serverData={serverData} />
71
+ </Suspense>
72
+
73
+ <div className="mt-12">
74
+ <Link href="/#contact" className="text-[14px] font-medium text-brand">
75
+ Have a project in mind? Start one →
76
+ </Link>
77
+ </div>
78
+ </section>
79
+
80
+ <SeedProjects />
81
+ </div>
82
+ );
83
+ }
@@ -8,21 +8,30 @@ import {
8
8
  } from "@pylonsync/sdk";
9
9
 
10
10
  // ---------------------------------------------------------------------------
11
- // agency — a site for a boutique studio that takes on a LIMITED number of
12
- // projects at a time. The realtime hook is scarcity: the hero shows how many
13
- // project slots are open this quarter, and the moment the owner books a new
14
- // client from the dashboard, that number drops for EVERYONE with the page
15
- // open no refresh. Open it in two tabs to see it.
11
+ // agency — a site + back-office for a boutique studio that takes on a LIMITED
12
+ // number of projects at a time. The public site is a marketing page (hero,
13
+ // services, selected work, case studies, process, team, testimonials, contact);
14
+ // the owner dashboard is the studio's back-office (pipeline, portfolio, clients,
15
+ // invoices). The realtime hook is scarcity: the hero shows how many project
16
+ // slots are open this quarter, and the moment the owner books a client, that
17
+ // number drops for EVERYONE with the page open — no refresh.
16
18
  //
17
- // Three entities:
18
- // • Inquiry — a "start a project" lead, with the prospect's name, email,
19
- // company + budget + message. Pure PII denies ALL client
20
- // reads/writes. The public site never reads an Inquiry; the
21
- // owner sees them only through the owner-gated inquiriesForOwner.
22
- // • Capacity — a single, PII-FREE row the public page reads live: the current
23
- // booking period + how many project slots are open. This is what
24
- // makes the hero counter realtime. Booking an inquiry decrements
25
- // it; the owner can reset it any time.
19
+ // Entities, split by who can see them:
20
+ //
21
+ // PUBLIC (marketing read by anyone, written only by the owner's functions)
22
+ // Capacity a single PII-FREE row: the booking window + open slots. Drives
23
+ // the live "N slots open" hero counter.
24
+ // • Project — a portfolio piece + case study (title, summary, tags, the
25
+ // challenge/approach/outcome write-up). `selected` flags the ones
26
+ // featured on the homepage; `published` hides drafts. Public-read
27
+ // so the marketing site + case-study pages render server-side.
28
+ //
29
+ // PRIVATE (back-office — deny ALL client access; owner-gated functions only)
30
+ // • Inquiry — a "start a project" lead (name, email, company, budget, msg).
31
+ // • Client — a CRM contact (name, company, email, phone, notes, status).
32
+ // • Invoice — a bill tied to a client (+ optional project): amount, status,
33
+ // issue/due dates. Money + client data, so it never leaves the
34
+ // owner-gated functions.
26
35
  // • User — the studio owner's account for the dashboard.
27
36
  // ---------------------------------------------------------------------------
28
37
 
@@ -62,6 +71,96 @@ const Capacity = entity(
62
71
  {},
63
72
  );
64
73
 
74
+ // A portfolio piece + its case study. PUBLIC marketing content — no PII — so it
75
+ // reads publicly: the homepage shows the `selected` ones, /work lists every
76
+ // `published` one, and /work/[slug] renders the full case study, all
77
+ // server-side for SEO + first paint. Only the owner's functions write it
78
+ // (upsertProject / setProjectFlags / deleteProject / seedProjects), so a visitor
79
+ // can read the portfolio but never edit it.
80
+ //
81
+ // • selected — featured on the homepage "Selected work" grid. Toggling it in
82
+ // the dashboard re-curates the homepage.
83
+ // • published — false = a draft, hidden from the public site (the owner still
84
+ // sees it in the dashboard, which reads every row).
85
+ // • order — manual sort (ascending) within the grids.
86
+ const Project = entity(
87
+ "Project",
88
+ {
89
+ title: field.string(),
90
+ slug: field.string(), // URL segment for /work/[slug]
91
+ client: field.string().default(""), // display label, e.g. "Fintech · 0→1"
92
+ summary: field.string().default(""), // one-liner on the card
93
+ year: field.string().optional(), // "2026"
94
+ tags: field.string().optional(), // comma-separated → chips
95
+ selected: field.boolean().default(false), // featured on the homepage
96
+ published: field.boolean().default(true), // false = draft, hidden publicly
97
+ order: field.int().default(0), // manual sort
98
+ // Case-study body — three short sections render on /work/[slug].
99
+ challenge: field.string().optional(),
100
+ approach: field.string().optional(),
101
+ outcome: field.string().optional(),
102
+ liveUrl: field.string().optional(),
103
+ createdAt: field.datetime().defaultNow(),
104
+ },
105
+ {
106
+ indexes: [
107
+ { name: "by_slug", fields: ["slug"], unique: true },
108
+ { name: "by_created", fields: ["createdAt"], unique: false },
109
+ ],
110
+ },
111
+ );
112
+
113
+ // A CRM contact — the people behind booked projects. Name/email/phone are PII,
114
+ // so it denies ALL client access; the dashboard reads + writes it only through
115
+ // the owner-gated functions (clientsForOwner / upsertClient / deleteClient).
116
+ const Client = entity(
117
+ "Client",
118
+ {
119
+ name: field.string(),
120
+ company: field.string().optional(),
121
+ email: field.string().optional(),
122
+ phone: field.string().optional(),
123
+ status: field.string().default("prospect"), // "prospect" | "active" | "past"
124
+ notes: field.string().optional(),
125
+ createdAt: field.datetime().defaultNow(),
126
+ },
127
+ { indexes: [{ name: "by_created", fields: ["createdAt"], unique: false }] },
128
+ );
129
+
130
+ // A bill tied to a client (and optionally a project). This is MONEY plus client
131
+ // data, so like Client it denies all client access — invoices are owner-only,
132
+ // read + written through invoicesForOwner / upsertInvoice / setInvoiceStatus /
133
+ // deleteInvoice. `amountCents` is stored as integer cents to avoid float money
134
+ // bugs. `clientName` / `projectTitle` are denormalized for display so the list
135
+ // renders without extra joins.
136
+ const Invoice = entity(
137
+ "Invoice",
138
+ {
139
+ number: field.string(), // "INV-001"
140
+ clientId: field.string(),
141
+ clientName: field.string().default(""),
142
+ projectId: field.string().optional(),
143
+ projectTitle: field.string().optional(),
144
+ // Line items as a JSON-encoded array of { description, quantity, unitCents }
145
+ // (Pylon has no JSON column type, so it's a string). `amountCents` is the
146
+ // computed total, kept in sync on write so the list + totals don't have to
147
+ // re-parse every row. The case-study PDF + the invoice view render the items.
148
+ lineItems: field.string().optional(),
149
+ amountCents: field.int().default(0),
150
+ status: field.string().default("draft"), // "draft" | "sent" | "paid" | "overdue"
151
+ issuedAt: field.string().optional(), // ISO date "2026-06-01"
152
+ dueAt: field.string().optional(),
153
+ notes: field.string().optional(),
154
+ createdAt: field.datetime().defaultNow(),
155
+ },
156
+ {
157
+ indexes: [
158
+ { name: "by_client", fields: ["clientId"], unique: false },
159
+ { name: "by_created", fields: ["createdAt"], unique: false },
160
+ ],
161
+ },
162
+ );
163
+
65
164
  // The studio owner's account. Email/password auth is built in against an entity
66
165
  // named "User" (passwordHash is server-only). The dashboard is gated to the
67
166
  // owner — see PYLON_OWNER_EMAIL in lib/owner.ts + the owner-only functions.
@@ -105,6 +204,45 @@ const capacityPolicy = policy({
105
204
  allowDelete: "false",
106
205
  });
107
206
 
207
+ // Projects are PUBLIC to READ — they're marketing content (portfolio + case
208
+ // studies), so the site renders them server-side for SEO. Clients can't WRITE
209
+ // them: only the owner-gated functions do. Drafts (`published == false`) are
210
+ // filtered out in the public read paths (the homepage, /work, /work/[slug]);
211
+ // the dashboard reads every row. (We keep the read open rather than gating on
212
+ // `published` in the policy so the owner's dashboard can list drafts with the
213
+ // same live `db.useQuery` the public uses — simplest path, and a draft case
214
+ // study is not sensitive the way a lead or an invoice is.)
215
+ const projectPolicy = policy({
216
+ name: "project_public_read",
217
+ entity: "Project",
218
+ allowRead: "true",
219
+ allowInsert: "false",
220
+ allowUpdate: "false",
221
+ allowDelete: "false",
222
+ });
223
+
224
+ // Clients + Invoices are the studio's back-office: contact PII and money. Both
225
+ // deny ALL client access, exactly like Inquiry — they're never read or written
226
+ // over entity sync. The owner reaches them only through the owner-gated
227
+ // functions, which check PYLON_OWNER_EMAIL and use ctx.db.unsafe.
228
+ const clientPolicy = policy({
229
+ name: "client_private",
230
+ entity: "Client",
231
+ allowRead: "false",
232
+ allowInsert: "false",
233
+ allowUpdate: "false",
234
+ allowDelete: "false",
235
+ });
236
+
237
+ const invoicePolicy = policy({
238
+ name: "invoice_private",
239
+ entity: "Invoice",
240
+ allowRead: "false",
241
+ allowInsert: "false",
242
+ allowUpdate: "false",
243
+ allowDelete: "false",
244
+ });
245
+
108
246
  const userPolicy = policy({
109
247
  name: "user_self",
110
248
  entity: "User",
@@ -117,13 +255,24 @@ const userPolicy = policy({
117
255
  const manifest = buildManifest({
118
256
  name: "__APP_NAME__",
119
257
  version: "0.1.0",
120
- entities: [Inquiry, Capacity, User],
121
- // submitInquiry / seedCapacity (public) + inquiriesForOwner / bookInquiry /
122
- // declineInquiry / setCapacity (owner-gated) live in functions/ and are
123
- // discovered automatically — they don't need listing here.
258
+ entities: [Inquiry, Capacity, Project, Client, Invoice, User],
259
+ // Functions live in functions/ and are discovered automatically:
260
+ // public: submitInquiry, seedCapacity, seedProjects
261
+ // owner: inquiriesForOwner, bookInquiry, declineInquiry, setCapacity,
262
+ // upsertProject, setProjectFlags, deleteProject,
263
+ // clientsForOwner, upsertClient, deleteClient,
264
+ // invoicesForOwner, upsertInvoice, setInvoiceStatus, deleteInvoice,
265
+ // seedStudioBackoffice
124
266
  queries: [],
125
267
  actions: [],
126
- policies: [inquiryPolicy, capacityPolicy, userPolicy],
268
+ policies: [
269
+ inquiryPolicy,
270
+ capacityPolicy,
271
+ projectPolicy,
272
+ clientPolicy,
273
+ invoicePolicy,
274
+ userPolicy,
275
+ ],
127
276
  // Email/password is on by default against the User entity above. No orgs, no
128
277
  // billing — a single studio is single-tenant (one business, one owner).
129
278
  auth: auth(),