@pylonsync/create-pylon 0.3.275 → 0.3.277
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 +1 -1
- package/package.json +1 -1
- package/templates/agency/app/dashboard/dashboard-client.tsx +1213 -59
- package/templates/agency/app/layout.tsx +1 -1
- package/templates/agency/app/page.tsx +72 -30
- package/templates/agency/app/seeder.tsx +26 -0
- package/templates/agency/app/work/[slug]/page.tsx +182 -0
- package/templates/agency/app/work/page.tsx +83 -0
- package/templates/agency/app.ts +168 -19
- package/templates/agency/components/marketing.tsx +39 -0
- package/templates/agency/functions/clientsForOwner.ts +27 -0
- package/templates/agency/functions/deleteClient.ts +27 -0
- package/templates/agency/functions/deleteInvoice.ts +19 -0
- package/templates/agency/functions/deleteProject.ts +20 -0
- package/templates/agency/functions/invoicesForOwner.ts +27 -0
- package/templates/agency/functions/seedProjects.ts +41 -0
- package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
- package/templates/agency/functions/setInvoiceStatus.ts +27 -0
- package/templates/agency/functions/setProjectFlags.ts +35 -0
- package/templates/agency/functions/upsertClient.ts +73 -0
- package/templates/agency/functions/upsertInvoice.ts +113 -0
- package/templates/agency/functions/upsertProject.ts +97 -0
- package/templates/agency/lib/agency.ts +165 -3
- package/templates/agency/lib/invoice-pdf.tsx +174 -0
- package/templates/agency/lib/site.config.ts +180 -1
- package/templates/agency/package.json +2 -1
- package/templates/ai-chat/app/chat-client.tsx +354 -41
- package/templates/ai-chat/functions/deleteConversation.ts +33 -0
- package/templates/ai-studio/app/studio-client.tsx +172 -29
- package/templates/ai-studio/app.ts +7 -7
- 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="
|
|
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.
|
|
24
|
-
//
|
|
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
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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'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'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
|
+
}
|
package/templates/agency/app.ts
CHANGED
|
@@ -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
|
|
12
|
-
// projects at a time. The
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// •
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
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: [
|
|
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(),
|