@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
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
// Shared agency types. The Inquiry
|
|
2
|
-
// PII); the client imports only the
|
|
1
|
+
// Shared agency types. The Inquiry / Client / Invoice rows are what the owner
|
|
2
|
+
// dashboard sees (with PII + money); the client imports only the types, never
|
|
3
|
+
// server code. Project is public, so it's read on both the marketing site and
|
|
4
|
+
// the dashboard.
|
|
3
5
|
|
|
4
6
|
export interface InquiryRow {
|
|
5
7
|
id: string;
|
|
@@ -15,7 +17,9 @@ export interface InquiryRow {
|
|
|
15
17
|
|
|
16
18
|
// inquiriesForOwner returns a discriminated result rather than throwing on a
|
|
17
19
|
// non-owner (a query has no `ctx.error`; a bare throw becomes a stripped
|
|
18
|
-
// HANDLER_ERROR). A non-owner gets `{ authorized: false }` and NO data.
|
|
20
|
+
// HANDLER_ERROR). A non-owner gets `{ authorized: false }` and NO data. The
|
|
21
|
+
// other owner-gated queries (clientsForOwner / invoicesForOwner) follow the
|
|
22
|
+
// same shape — see OwnerResult below.
|
|
19
23
|
export type OwnerInquiriesResult =
|
|
20
24
|
| { authorized: true; inquiries: InquiryRow[] }
|
|
21
25
|
| { authorized: false };
|
|
@@ -25,3 +29,161 @@ export interface CapacityData {
|
|
|
25
29
|
label: string; // booking window, e.g. "Q3 2026"
|
|
26
30
|
openSlots: number;
|
|
27
31
|
}
|
|
32
|
+
|
|
33
|
+
// A portfolio piece + case study. Public — read on the marketing site AND in
|
|
34
|
+
// the dashboard. `selected` features it on the homepage; `published` toggles
|
|
35
|
+
// whether the public site shows it (drafts stay owner-only).
|
|
36
|
+
export interface ProjectRow {
|
|
37
|
+
id: string;
|
|
38
|
+
title: string;
|
|
39
|
+
slug: string;
|
|
40
|
+
client: string;
|
|
41
|
+
summary: string;
|
|
42
|
+
year?: string | null;
|
|
43
|
+
tags?: string | null; // comma-separated
|
|
44
|
+
selected: boolean;
|
|
45
|
+
published: boolean;
|
|
46
|
+
order: number;
|
|
47
|
+
challenge?: string | null;
|
|
48
|
+
approach?: string | null;
|
|
49
|
+
outcome?: string | null;
|
|
50
|
+
liveUrl?: string | null;
|
|
51
|
+
createdAt: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A CRM contact (owner-only — PII).
|
|
55
|
+
export interface ClientRow {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
company?: string | null;
|
|
59
|
+
email?: string | null;
|
|
60
|
+
phone?: string | null;
|
|
61
|
+
status: string; // "prospect" | "active" | "past"
|
|
62
|
+
notes?: string | null;
|
|
63
|
+
createdAt: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// One line on an invoice. `unitCents` is the per-unit price in integer cents;
|
|
67
|
+
// the line total is `quantity * unitCents`.
|
|
68
|
+
export interface InvoiceLineItem {
|
|
69
|
+
description: string;
|
|
70
|
+
quantity: number;
|
|
71
|
+
unitCents: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A bill tied to a client, + optional project (owner-only — money + PII).
|
|
75
|
+
// `amountCents` is the integer-cents total; `lineItems` is the JSON-encoded
|
|
76
|
+
// breakdown (parse with `parseLineItems`).
|
|
77
|
+
export interface InvoiceRow {
|
|
78
|
+
id: string;
|
|
79
|
+
number: string;
|
|
80
|
+
clientId: string;
|
|
81
|
+
clientName: string;
|
|
82
|
+
projectId?: string | null;
|
|
83
|
+
projectTitle?: string | null;
|
|
84
|
+
lineItems?: string | null; // JSON array of InvoiceLineItem
|
|
85
|
+
amountCents: number;
|
|
86
|
+
status: string; // "draft" | "sent" | "paid" | "overdue"
|
|
87
|
+
issuedAt?: string | null;
|
|
88
|
+
dueAt?: string | null;
|
|
89
|
+
notes?: string | null;
|
|
90
|
+
createdAt: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Owner-gated queries that carry private rows use the same discriminated shape
|
|
94
|
+
// as inquiries: `{ authorized: false }` for a non-owner, otherwise the data.
|
|
95
|
+
export type OwnerClientsResult =
|
|
96
|
+
| { authorized: true; clients: ClientRow[] }
|
|
97
|
+
| { authorized: false };
|
|
98
|
+
|
|
99
|
+
export type OwnerInvoicesResult =
|
|
100
|
+
| { authorized: true; invoices: InvoiceRow[] }
|
|
101
|
+
| { authorized: false };
|
|
102
|
+
|
|
103
|
+
/* ------------------------------ helpers ------------------------------ */
|
|
104
|
+
|
|
105
|
+
// Split a comma-separated tag string into trimmed, non-empty tags.
|
|
106
|
+
export function parseTags(tags?: string | null): string[] {
|
|
107
|
+
return (tags ?? "")
|
|
108
|
+
.split(",")
|
|
109
|
+
.map((t) => t.trim())
|
|
110
|
+
.filter(Boolean);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Format integer cents as a currency string ("$1,500.00"). Whole-dollar amounts
|
|
114
|
+
// drop the cents ("$1,500").
|
|
115
|
+
export function money(cents: number): string {
|
|
116
|
+
const dollars = (cents || 0) / 100;
|
|
117
|
+
const whole = Number.isInteger(dollars);
|
|
118
|
+
return dollars.toLocaleString("en-US", {
|
|
119
|
+
style: "currency",
|
|
120
|
+
currency: "USD",
|
|
121
|
+
minimumFractionDigits: whole ? 0 : 2,
|
|
122
|
+
maximumFractionDigits: 2,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Parse the JSON line-item blob off an invoice into a clean, typed array.
|
|
127
|
+
// Tolerant: bad JSON or missing fields yield an empty list / zeroed fields
|
|
128
|
+
// rather than throwing, so a malformed row never crashes the dashboard or PDF.
|
|
129
|
+
export function parseLineItems(raw?: string | null): InvoiceLineItem[] {
|
|
130
|
+
if (!raw) return [];
|
|
131
|
+
try {
|
|
132
|
+
const parsed = JSON.parse(raw);
|
|
133
|
+
if (!Array.isArray(parsed)) return [];
|
|
134
|
+
return parsed.map((it) => ({
|
|
135
|
+
description: String(it?.description ?? ""),
|
|
136
|
+
quantity: Number(it?.quantity) || 0,
|
|
137
|
+
unitCents: Math.trunc(Number(it?.unitCents) || 0),
|
|
138
|
+
}));
|
|
139
|
+
} catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Total of a line-item list, in integer cents.
|
|
145
|
+
export function lineItemsTotal(items: InvoiceLineItem[]): number {
|
|
146
|
+
return items.reduce((sum, it) => sum + Math.round(it.quantity * it.unitCents), 0);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Slugify a project title for its /work/[slug] URL.
|
|
150
|
+
export function slugify(s: string): string {
|
|
151
|
+
return s
|
|
152
|
+
.toLowerCase()
|
|
153
|
+
.trim()
|
|
154
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
155
|
+
.replace(/^-+|-+$/g, "")
|
|
156
|
+
.slice(0, 60);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A normalized project shape the public pages render — tags already split, the
|
|
160
|
+
// storage flags (selected/published/order) dropped. Built from a DB row
|
|
161
|
+
// (`viewFromRow`) or, before the portfolio is seeded, from a config case study,
|
|
162
|
+
// so the marketing pages render identically either way.
|
|
163
|
+
export interface ProjectView {
|
|
164
|
+
slug: string;
|
|
165
|
+
title: string;
|
|
166
|
+
client: string;
|
|
167
|
+
summary: string;
|
|
168
|
+
year?: string | null;
|
|
169
|
+
tags: string[];
|
|
170
|
+
challenge?: string | null;
|
|
171
|
+
approach?: string | null;
|
|
172
|
+
outcome?: string | null;
|
|
173
|
+
liveUrl?: string | null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function viewFromRow(r: ProjectRow): ProjectView {
|
|
177
|
+
return {
|
|
178
|
+
slug: r.slug,
|
|
179
|
+
title: r.title,
|
|
180
|
+
client: r.client,
|
|
181
|
+
summary: r.summary,
|
|
182
|
+
year: r.year,
|
|
183
|
+
tags: parseTags(r.tags),
|
|
184
|
+
challenge: r.challenge,
|
|
185
|
+
approach: r.approach,
|
|
186
|
+
outcome: r.outcome,
|
|
187
|
+
liveUrl: r.liveUrl,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Document, Page, View, Text, StyleSheet, pdf } from "@react-pdf/renderer";
|
|
3
|
+
import { money, type InvoiceLineItem } from "./agency";
|
|
4
|
+
|
|
5
|
+
// Invoice → PDF, with @react-pdf/renderer (the React-to-PDF renderer — note
|
|
6
|
+
// `react-pdf` proper is a *viewer* for existing PDFs; this is the generator).
|
|
7
|
+
//
|
|
8
|
+
// This module is imported DYNAMICALLY from the dashboard's "Download PDF" click
|
|
9
|
+
// handler (`await import("@/lib/invoice-pdf")`), never during render — so the
|
|
10
|
+
// ~1MB renderer + this document never touch SSR or the initial client bundle;
|
|
11
|
+
// it loads only when the owner actually exports a bill. The PDF mirrors the
|
|
12
|
+
// on-screen invoice view so what you see is what you download.
|
|
13
|
+
|
|
14
|
+
export interface InvoicePdfData {
|
|
15
|
+
number: string;
|
|
16
|
+
status: string;
|
|
17
|
+
issuedAt?: string | null;
|
|
18
|
+
dueAt?: string | null;
|
|
19
|
+
notes?: string | null;
|
|
20
|
+
projectTitle?: string | null;
|
|
21
|
+
items: InvoiceLineItem[];
|
|
22
|
+
amountCents: number;
|
|
23
|
+
billTo: { name: string; company?: string | null; email?: string | null };
|
|
24
|
+
studio: {
|
|
25
|
+
name: string;
|
|
26
|
+
addressLines: string[];
|
|
27
|
+
paymentTerms: string;
|
|
28
|
+
footerNote: string;
|
|
29
|
+
brandColor: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Build (and return) the invoice PDF as a Blob the caller can download.
|
|
34
|
+
export async function buildInvoiceBlob(data: InvoicePdfData): Promise<Blob> {
|
|
35
|
+
return await pdf(<InvoiceDocument data={data} />).toBlob();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const STATUS_COLOR: Record<string, string> = {
|
|
39
|
+
draft: "#71717a",
|
|
40
|
+
sent: "#2563eb",
|
|
41
|
+
paid: "#16a34a",
|
|
42
|
+
overdue: "#dc2626",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function InvoiceDocument({ data }: { data: InvoicePdfData }) {
|
|
46
|
+
const { studio, billTo } = data;
|
|
47
|
+
const s = makeStyles(studio.brandColor);
|
|
48
|
+
// A bill with no explicit line items still prints one line for its total.
|
|
49
|
+
const items =
|
|
50
|
+
data.items.length > 0
|
|
51
|
+
? data.items
|
|
52
|
+
: [{ description: "Services", quantity: 1, unitCents: data.amountCents }];
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Document title={`Invoice ${data.number}`} author={studio.name}>
|
|
56
|
+
<Page size="A4" style={s.page}>
|
|
57
|
+
{/* Header */}
|
|
58
|
+
<View style={s.header}>
|
|
59
|
+
<View>
|
|
60
|
+
<Text style={s.studioName}>{studio.name}</Text>
|
|
61
|
+
{studio.addressLines.map((l, i) => (
|
|
62
|
+
<Text key={i} style={s.muted}>
|
|
63
|
+
{l}
|
|
64
|
+
</Text>
|
|
65
|
+
))}
|
|
66
|
+
</View>
|
|
67
|
+
<View style={s.headerRight}>
|
|
68
|
+
<Text style={s.invoiceLabel}>INVOICE</Text>
|
|
69
|
+
<Text style={s.invoiceNumber}>{data.number}</Text>
|
|
70
|
+
<Text style={[s.status, { color: STATUS_COLOR[data.status] ?? "#71717a" }]}>
|
|
71
|
+
{data.status.toUpperCase()}
|
|
72
|
+
</Text>
|
|
73
|
+
</View>
|
|
74
|
+
</View>
|
|
75
|
+
|
|
76
|
+
<View style={s.rule} />
|
|
77
|
+
|
|
78
|
+
{/* Bill-to + meta */}
|
|
79
|
+
<View style={s.cols}>
|
|
80
|
+
<View style={s.col}>
|
|
81
|
+
<Text style={s.sectionLabel}>BILL TO</Text>
|
|
82
|
+
<Text style={s.strong}>{billTo.name}</Text>
|
|
83
|
+
{billTo.company ? <Text style={s.muted}>{billTo.company}</Text> : null}
|
|
84
|
+
{billTo.email ? <Text style={s.muted}>{billTo.email}</Text> : null}
|
|
85
|
+
</View>
|
|
86
|
+
<View style={s.colRight}>
|
|
87
|
+
{data.projectTitle ? <Meta s={s} label="Project" value={data.projectTitle} /> : null}
|
|
88
|
+
{data.issuedAt ? <Meta s={s} label="Issued" value={data.issuedAt} /> : null}
|
|
89
|
+
{data.dueAt ? <Meta s={s} label="Due" value={data.dueAt} /> : null}
|
|
90
|
+
<Meta s={s} label="Terms" value={studio.paymentTerms} />
|
|
91
|
+
</View>
|
|
92
|
+
</View>
|
|
93
|
+
|
|
94
|
+
{/* Line items */}
|
|
95
|
+
<View style={s.table}>
|
|
96
|
+
<View style={s.tableHead}>
|
|
97
|
+
<Text style={[s.cell, s.cDesc, s.headCell]}>Description</Text>
|
|
98
|
+
<Text style={[s.cell, s.cQty, s.headCell]}>Qty</Text>
|
|
99
|
+
<Text style={[s.cell, s.cUnit, s.headCell]}>Unit</Text>
|
|
100
|
+
<Text style={[s.cell, s.cAmt, s.headCell]}>Amount</Text>
|
|
101
|
+
</View>
|
|
102
|
+
{items.map((it, i) => (
|
|
103
|
+
<View key={i} style={s.tableRow}>
|
|
104
|
+
<Text style={[s.cell, s.cDesc]}>{it.description}</Text>
|
|
105
|
+
<Text style={[s.cell, s.cQty]}>{it.quantity}</Text>
|
|
106
|
+
<Text style={[s.cell, s.cUnit]}>{money(it.unitCents)}</Text>
|
|
107
|
+
<Text style={[s.cell, s.cAmt]}>{money(Math.round(it.quantity * it.unitCents))}</Text>
|
|
108
|
+
</View>
|
|
109
|
+
))}
|
|
110
|
+
<View style={s.totalRow}>
|
|
111
|
+
<Text style={s.totalLabel}>Total due</Text>
|
|
112
|
+
<Text style={s.totalValue}>{money(data.amountCents)}</Text>
|
|
113
|
+
</View>
|
|
114
|
+
</View>
|
|
115
|
+
|
|
116
|
+
{data.notes ? (
|
|
117
|
+
<View style={s.notes}>
|
|
118
|
+
<Text style={s.sectionLabel}>NOTES</Text>
|
|
119
|
+
<Text style={s.muted}>{data.notes}</Text>
|
|
120
|
+
</View>
|
|
121
|
+
) : null}
|
|
122
|
+
|
|
123
|
+
<Text style={s.footer} fixed>
|
|
124
|
+
{studio.footerNote}
|
|
125
|
+
</Text>
|
|
126
|
+
</Page>
|
|
127
|
+
</Document>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function Meta({ s, label, value }: { s: ReturnType<typeof makeStyles>; label: string; value: string }) {
|
|
132
|
+
return (
|
|
133
|
+
<View style={s.metaRow}>
|
|
134
|
+
<Text style={s.metaLabel}>{label}</Text>
|
|
135
|
+
<Text style={s.metaValue}>{value}</Text>
|
|
136
|
+
</View>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function makeStyles(brand: string) {
|
|
141
|
+
return StyleSheet.create({
|
|
142
|
+
page: { padding: 48, fontSize: 10, color: "#27272a", fontFamily: "Helvetica", lineHeight: 1.4 },
|
|
143
|
+
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" },
|
|
144
|
+
studioName: { fontSize: 18, fontFamily: "Helvetica-Bold", color: "#18181b", marginBottom: 4 },
|
|
145
|
+
headerRight: { alignItems: "flex-end" },
|
|
146
|
+
invoiceLabel: { fontSize: 11, letterSpacing: 2, color: brand, fontFamily: "Helvetica-Bold" },
|
|
147
|
+
invoiceNumber: { fontSize: 13, marginTop: 2, fontFamily: "Helvetica-Bold", color: "#18181b" },
|
|
148
|
+
status: { fontSize: 9, marginTop: 4, fontFamily: "Helvetica-Bold", letterSpacing: 1 },
|
|
149
|
+
muted: { color: "#71717a" },
|
|
150
|
+
rule: { borderBottomWidth: 1, borderBottomColor: "#e4e4e7", marginVertical: 18 },
|
|
151
|
+
cols: { flexDirection: "row", justifyContent: "space-between", marginBottom: 24 },
|
|
152
|
+
col: { width: "55%" },
|
|
153
|
+
colRight: { width: "40%" },
|
|
154
|
+
sectionLabel: { fontSize: 8, letterSpacing: 1.5, color: "#a1a1aa", fontFamily: "Helvetica-Bold", marginBottom: 5 },
|
|
155
|
+
strong: { fontFamily: "Helvetica-Bold", color: "#18181b", fontSize: 11 },
|
|
156
|
+
metaRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 3 },
|
|
157
|
+
metaLabel: { color: "#a1a1aa" },
|
|
158
|
+
metaValue: { color: "#27272a", fontFamily: "Helvetica-Bold" },
|
|
159
|
+
table: { marginTop: 4 },
|
|
160
|
+
tableHead: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: "#27272a", paddingBottom: 6 },
|
|
161
|
+
headCell: { fontFamily: "Helvetica-Bold", color: "#18181b", fontSize: 9, letterSpacing: 0.5 },
|
|
162
|
+
tableRow: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: "#f4f4f5", paddingVertical: 8 },
|
|
163
|
+
cell: { fontSize: 10 },
|
|
164
|
+
cDesc: { width: "52%", paddingRight: 8 },
|
|
165
|
+
cQty: { width: "12%", textAlign: "right" },
|
|
166
|
+
cUnit: { width: "18%", textAlign: "right" },
|
|
167
|
+
cAmt: { width: "18%", textAlign: "right" },
|
|
168
|
+
totalRow: { flexDirection: "row", justifyContent: "flex-end", marginTop: 12, paddingTop: 4 },
|
|
169
|
+
totalLabel: { fontSize: 11, fontFamily: "Helvetica-Bold", color: "#18181b", marginRight: 16 },
|
|
170
|
+
totalValue: { fontSize: 14, fontFamily: "Helvetica-Bold", color: brand, minWidth: 90, textAlign: "right" },
|
|
171
|
+
notes: { marginTop: 28 },
|
|
172
|
+
footer: { position: "absolute", bottom: 36, left: 48, right: 48, fontSize: 9, color: "#a1a1aa", textAlign: "center" },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -28,11 +28,61 @@ export type BaseConfig = {
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
export type Service = { title: string; body: string; icon?: string };
|
|
31
|
-
|
|
31
|
+
|
|
32
|
+
// A portfolio piece + case study. `seedProjects` writes these into the public
|
|
33
|
+
// Project entity on first visit; after that the owner curates them from the
|
|
34
|
+
// dashboard. `selected` features it on the homepage; the challenge/approach/
|
|
35
|
+
// outcome render on the /work/[slug] case-study page. `slug` is the URL segment
|
|
36
|
+
// (auto-derived from the title if omitted).
|
|
37
|
+
export type CaseStudy = {
|
|
38
|
+
title: string;
|
|
39
|
+
slug?: string;
|
|
40
|
+
client: string; // display label, e.g. "Fintech · 0→1"
|
|
41
|
+
summary: string;
|
|
42
|
+
year?: string;
|
|
43
|
+
tags: string[];
|
|
44
|
+
selected?: boolean; // featured on the homepage "Selected work" grid
|
|
45
|
+
challenge?: string;
|
|
46
|
+
approach?: string;
|
|
47
|
+
outcome?: string;
|
|
48
|
+
liveUrl?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
32
51
|
export type ProcessStep = { title: string; body: string };
|
|
33
52
|
export type TeamMember = { name: string; role: string };
|
|
34
53
|
export type Testimonial = { quote: string; name: string; role: string };
|
|
35
54
|
|
|
55
|
+
// Demo back-office rows. `seedStudioBackoffice` (owner-gated) writes these into
|
|
56
|
+
// the private Client + Invoice entities on the owner's first dashboard visit, so
|
|
57
|
+
// the dashboard isn't an empty shell. `invoices[].client` matches a `clients[]`
|
|
58
|
+
// name; `invoices[].projectSlug` (optional) ties a bill to a case study.
|
|
59
|
+
export type ClientSeed = {
|
|
60
|
+
name: string;
|
|
61
|
+
company?: string;
|
|
62
|
+
email?: string;
|
|
63
|
+
phone?: string;
|
|
64
|
+
status?: "prospect" | "active" | "past";
|
|
65
|
+
notes?: string;
|
|
66
|
+
};
|
|
67
|
+
export type InvoiceSeed = {
|
|
68
|
+
number: string;
|
|
69
|
+
client: string; // matches a clients[].name
|
|
70
|
+
projectSlug?: string; // matches a work.items[].slug
|
|
71
|
+
// Line items drive the total — `amountCents` is computed from them on seed.
|
|
72
|
+
lineItems: { description: string; quantity: number; unitCents: number }[];
|
|
73
|
+
status?: "draft" | "sent" | "paid" | "overdue";
|
|
74
|
+
issuedAt?: string; // "2026-04-01"
|
|
75
|
+
dueAt?: string;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// The studio's billing identity — the "from" block + terms on every invoice and
|
|
79
|
+
// its PDF. Edit these to make the invoices yours.
|
|
80
|
+
export type Billing = {
|
|
81
|
+
addressLines: string[]; // shown under the studio name on the invoice
|
|
82
|
+
paymentTerms: string; // e.g. "Net 30"
|
|
83
|
+
footerNote: string; // a thank-you / payment note in the invoice footer
|
|
84
|
+
};
|
|
85
|
+
|
|
36
86
|
export type AgencyConfig = BaseConfig & {
|
|
37
87
|
hero: {
|
|
38
88
|
tagline: string;
|
|
@@ -48,6 +98,10 @@ export type AgencyConfig = BaseConfig & {
|
|
|
48
98
|
logos: { eyebrow: string; names: string[] };
|
|
49
99
|
services: { eyebrow: string; headline: string; items: Service[] };
|
|
50
100
|
work: { eyebrow: string; headline: string; items: CaseStudy[] };
|
|
101
|
+
// The studio's invoice "from" identity + terms.
|
|
102
|
+
billing: Billing;
|
|
103
|
+
// Demo CRM + billing rows seeded into the owner dashboard on first visit.
|
|
104
|
+
backoffice: { clients: ClientSeed[]; invoices: InvoiceSeed[] };
|
|
51
105
|
process: { eyebrow: string; headline: string; steps: ProcessStep[] };
|
|
52
106
|
team: { eyebrow: string; headline: string; members: TeamMember[] };
|
|
53
107
|
testimonials?: { eyebrow: string; headline: string; items: Testimonial[] };
|
|
@@ -143,31 +197,156 @@ export const siteConfig: AgencyConfig = {
|
|
|
143
197
|
items: [
|
|
144
198
|
{
|
|
145
199
|
title: "Ledger",
|
|
200
|
+
slug: "ledger",
|
|
146
201
|
client: "Fintech · 0→1",
|
|
202
|
+
year: "2026",
|
|
147
203
|
summary: "A consumer banking app from first sketch to App Store launch in one quarter.",
|
|
148
204
|
tags: ["Product design", "iOS", "Brand"],
|
|
205
|
+
selected: true,
|
|
206
|
+
challenge:
|
|
207
|
+
"A two-founder fintech had funding and a thesis, but no product, no brand, and a hard 12-week runway to a launchable app the App Store would approve.",
|
|
208
|
+
approach:
|
|
209
|
+
"We ran a one-week scope sprint, then designed and built in parallel — a clickable prototype on real data by week three, weekly TestFlight builds after that. Brand and UI moved together so nothing felt bolted on.",
|
|
210
|
+
outcome:
|
|
211
|
+
"Shipped to the App Store in the 11th week with a 4.8★ launch rating. The founders closed their seed round two weeks later using the live app as the demo.",
|
|
212
|
+
liveUrl: "https://example.com",
|
|
149
213
|
},
|
|
150
214
|
{
|
|
151
215
|
title: "Atlas Health",
|
|
216
|
+
slug: "atlas-health",
|
|
152
217
|
client: "Healthcare · Platform",
|
|
218
|
+
year: "2025",
|
|
153
219
|
summary: "Rebuilt a clinical scheduling tool used by 4,000 providers, with zero downtime.",
|
|
154
220
|
tags: ["Web", "Design system"],
|
|
221
|
+
selected: true,
|
|
222
|
+
challenge:
|
|
223
|
+
"A scheduling platform 4,000 clinicians depended on daily had become impossible to change — every release risked an outage no hospital could tolerate.",
|
|
224
|
+
approach:
|
|
225
|
+
"We introduced a typed design system and migrated screen by screen behind feature flags, shipping to a few clinics at a time and watching the metrics before widening the rollout.",
|
|
226
|
+
outcome:
|
|
227
|
+
"Replaced the entire front end over a quarter with zero scheduled downtime, and cut the time to ship a new screen from two weeks to two days.",
|
|
155
228
|
},
|
|
156
229
|
{
|
|
157
230
|
title: "Cohort",
|
|
231
|
+
slug: "cohort",
|
|
158
232
|
client: "B2B SaaS · Rebrand",
|
|
233
|
+
year: "2025",
|
|
159
234
|
summary: "New identity and marketing site that lifted demo requests 40% in six weeks.",
|
|
160
235
|
tags: ["Brand", "Web"],
|
|
236
|
+
selected: true,
|
|
237
|
+
challenge:
|
|
238
|
+
"A profitable B2B SaaS had outgrown the brand it launched with — the site read like a side project and was quietly losing enterprise deals at the first click.",
|
|
239
|
+
approach:
|
|
240
|
+
"A focused rebrand: new name treatment, a confident visual system, and a marketing site rebuilt around the two proof points buyers actually asked about.",
|
|
241
|
+
outcome:
|
|
242
|
+
"Demo requests rose 40% in the first six weeks, and the sales team stopped apologizing for the website on calls.",
|
|
161
243
|
},
|
|
162
244
|
{
|
|
163
245
|
title: "Vela",
|
|
246
|
+
slug: "vela",
|
|
164
247
|
client: "Logistics · Mobile",
|
|
248
|
+
year: "2024",
|
|
165
249
|
summary: "A driver app with live routing that cut dispatch calls in half.",
|
|
166
250
|
tags: ["Product design", "Android"],
|
|
251
|
+
selected: false,
|
|
252
|
+
challenge:
|
|
253
|
+
"Dispatchers spent their day on the phone because drivers had no live view of their own routes — every change meant a call.",
|
|
254
|
+
approach:
|
|
255
|
+
"We designed an offline-first driver app with live routing and a single, glanceable 'what's next' screen, built for one-handed use in a moving vehicle.",
|
|
256
|
+
outcome:
|
|
257
|
+
"Dispatch calls dropped by half within a month, and on-time delivery climbed eight points.",
|
|
167
258
|
},
|
|
168
259
|
],
|
|
169
260
|
},
|
|
170
261
|
|
|
262
|
+
backoffice: {
|
|
263
|
+
clients: [
|
|
264
|
+
{
|
|
265
|
+
name: "Erin Caldwell",
|
|
266
|
+
company: "Ledger",
|
|
267
|
+
email: "erin@ledger.example",
|
|
268
|
+
phone: "+1 (214) 555-0142",
|
|
269
|
+
status: "active",
|
|
270
|
+
notes: "Founder. Seed round closed; discussing a phase-2 retainer for Q4.",
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
name: "Tom Reyes",
|
|
274
|
+
company: "Atlas Health",
|
|
275
|
+
email: "tom@atlashealth.example",
|
|
276
|
+
status: "active",
|
|
277
|
+
notes: "VP Product. Rollout complete; on a monthly maintenance retainer.",
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
name: "Sofia Marin",
|
|
281
|
+
company: "Cohort",
|
|
282
|
+
email: "sofia@cohort.example",
|
|
283
|
+
status: "past",
|
|
284
|
+
notes: "Rebrand shipped. Happy to be a reference.",
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
name: "Grant Whitaker",
|
|
288
|
+
company: "Northwind",
|
|
289
|
+
email: "grant@northwind.example",
|
|
290
|
+
status: "prospect",
|
|
291
|
+
notes: "Inbound about a 0→1 mobile app. Sent a proposal; awaiting reply.",
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
invoices: [
|
|
295
|
+
{
|
|
296
|
+
number: "INV-001",
|
|
297
|
+
client: "Erin Caldwell",
|
|
298
|
+
projectSlug: "ledger",
|
|
299
|
+
lineItems: [
|
|
300
|
+
{ description: "Product design & iOS build — Ledger (12-week engagement)", quantity: 1, unitCents: 4800000 },
|
|
301
|
+
],
|
|
302
|
+
status: "paid",
|
|
303
|
+
issuedAt: "2026-01-15",
|
|
304
|
+
dueAt: "2026-02-14",
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
number: "INV-002",
|
|
308
|
+
client: "Tom Reyes",
|
|
309
|
+
projectSlug: "atlas-health",
|
|
310
|
+
lineItems: [
|
|
311
|
+
{ description: "Platform rebuild & design system", quantity: 1, unitCents: 5500000 },
|
|
312
|
+
{ description: "Discovery & scoping sprint", quantity: 1, unitCents: 700000 },
|
|
313
|
+
],
|
|
314
|
+
status: "paid",
|
|
315
|
+
issuedAt: "2026-03-01",
|
|
316
|
+
dueAt: "2026-03-31",
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
number: "INV-003",
|
|
320
|
+
client: "Tom Reyes",
|
|
321
|
+
projectSlug: "atlas-health",
|
|
322
|
+
lineItems: [
|
|
323
|
+
{ description: "Maintenance retainer — June", quantity: 1, unitCents: 850000 },
|
|
324
|
+
],
|
|
325
|
+
status: "sent",
|
|
326
|
+
issuedAt: "2026-06-01",
|
|
327
|
+
dueAt: "2026-07-01",
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
number: "INV-004",
|
|
331
|
+
client: "Sofia Marin",
|
|
332
|
+
projectSlug: "cohort",
|
|
333
|
+
lineItems: [
|
|
334
|
+
{ description: "Brand identity & system", quantity: 1, unitCents: 1400000 },
|
|
335
|
+
{ description: "Marketing site design & build", quantity: 1, unitCents: 1000000 },
|
|
336
|
+
],
|
|
337
|
+
status: "overdue",
|
|
338
|
+
issuedAt: "2026-04-10",
|
|
339
|
+
dueAt: "2026-05-10",
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
billing: {
|
|
345
|
+
addressLines: ["Halyard Studio", "Dallas, TX", "hello@halyard.example"],
|
|
346
|
+
paymentTerms: "Net 30",
|
|
347
|
+
footerNote: "Thank you. Please reference the invoice number with payment.",
|
|
348
|
+
},
|
|
349
|
+
|
|
171
350
|
process: {
|
|
172
351
|
eyebrow: "How we work",
|
|
173
352
|
headline: "Senior, hands-on, and fast.",
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"clsx": "^2.1.1",
|
|
23
23
|
"tailwind-merge": "^2.5.0",
|
|
24
24
|
"lucide-react": "^0.460.0",
|
|
25
|
-
"@radix-ui/react-slot": "^1.1.0"
|
|
25
|
+
"@radix-ui/react-slot": "^1.1.0",
|
|
26
|
+
"@react-pdf/renderer": "^4.5.1"
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
28
29
|
"@pylonsync/cli": "^__PYLON_VERSION__",
|