@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.
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
@@ -1,5 +1,7 @@
1
- // Shared agency types. The Inquiry row is what the owner dashboard sees (with
2
- // PII); the client imports only the type, never server code.
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
- export type CaseStudy = { title: string; client: string; summary: string; tags: string[] };
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__",