Skip to content

Commit 3619ead

Browse files
committed
feat: rebrand from indigo to rose, add OG image, fix SPA routing
- Change primary accent color from indigo (#6366F1) to rose (#E11D48) - Update status colors: sent (blue), viewed (violet), overdue (orange), settled (green) - Add SEO meta tags and OG image (1200x630 PNG) - Fix Cloudflare Workers SPA routing with not_found_handling config - Swap demo video to new version (demo/kivo.mp4) - Update email templates and PDF invoice colors - Remove screenshots from README
1 parent 46b71be commit 3619ead

File tree

17 files changed

+624
-868
lines changed

17 files changed

+624
-868
lines changed

README.md

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,13 @@
22

33
Modern invoicing for freelancers and independent creators. Built on the Cloudflare developer platform.
44

5-
## Screenshots
6-
7-
![Dashboard](docs/screenshots/dashboard.png)
8-
*Dashboard with KPIs and recent invoices*
9-
10-
![Invoice Detail](docs/screenshots/invoice-detail.png)
11-
*Invoice detail view with line items and activity*
12-
13-
![Public Invoice](docs/screenshots/public-invoice.png)
14-
*Public invoice view with payment option*
15-
165
## Features
176

187
- **Authentication**: Email magic link authentication (passwordless)
198
- **Multi-tenant**: Each user only sees their own data
209
- **Clients**: Create, edit, archive clients with full contact details
2110
- **Invoices**: Full invoice lifecycle - draft, send, track, and get paid
22-
- **PDF Generation**: Branded PDF invoices stored in R2
11+
- **PDF Generation**: Real PDF invoices via Cloudflare Browser Rendering API, stored in R2
2312
- **Email Notifications**: Invoice delivery, reminders, payment receipts via Resend
2413
- **Payments**: Stripe Checkout integration with webhook handling
2514
- **Reminders**: Automatic payment reminders using Durable Objects
@@ -57,6 +46,7 @@ This unified deployment provides:
5746
- Hono for routing
5847
- D1 for relational data
5948
- R2 for PDF/asset storage
49+
- Browser Rendering API for PDF generation
6050
- Durable Objects for reminder scheduling
6151
- Cron Triggers for periodic reconciliation
6252
- Static Assets for serving the frontend
@@ -128,6 +118,10 @@ wrangler secret put STRIPE_SECRET_KEY
128118
# Stripe webhook secret
129119
wrangler secret put STRIPE_WEBHOOK_SECRET
130120
# (Get after creating webhook endpoint)
121+
122+
# Cloudflare Browser Rendering API token (for PDF generation)
123+
wrangler secret put CF_API_TOKEN
124+
# (Create at https://dash.cloudflare.com/profile/api-tokens with Browser Rendering permissions)
131125
```
132126

133127
### 4. Run Database Migrations
@@ -215,6 +209,8 @@ Your app will be available at: `https://kivo.<your-subdomain>.workers.dev`
215209
| `RESEND_API_KEY` | Resend API key for sending emails |
216210
| `STRIPE_SECRET_KEY` | Stripe secret key |
217211
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
212+
| `CF_ACCOUNT_ID` | Cloudflare account ID (for Browser Rendering API) |
213+
| `CF_API_TOKEN` | Cloudflare API token with Browser Rendering permissions |
218214
| `FRONTEND_URL` | Frontend URL (for CORS and email links) |
219215
| `API_URL` | API URL (for generating links) |
220216

apps/api/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
"dependencies": {
1818
"@kivo/shared": "*",
1919
"hono": "^4.0.0",
20-
"pdfkit": "^0.14.0",
2120
"stripe": "^14.10.0"
2221
},
2322
"devDependencies": {

apps/api/src/routes/invoices.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -572,12 +572,16 @@ invoices.post('/:id/pdf', async (c) => {
572572
'SELECT * FROM settings WHERE user_id = ?'
573573
).bind(userId).first<Settings>();
574574

575-
// Generate PDF
576-
const pdfService = new PDFService(c.env.STORAGE);
577-
const pdfData = await pdfService.generateInvoicePDF(invoice as InvoiceWithClient, settings!);
575+
if (!settings) {
576+
throw new ValidationError('User settings not found. Please configure your business settings first.');
577+
}
578+
579+
// Generate PDF using Browser Rendering API (falls back to HTML)
580+
const pdfService = new PDFService(c.env.STORAGE, c.env.CF_ACCOUNT_ID, c.env.CF_API_TOKEN);
581+
const { data: pdfData, isPdf } = await pdfService.generateInvoicePDF(invoice as InvoiceWithClient, settings);
578582

579-
// Store PDF
580-
const pdfKey = await pdfService.storePDF(userId, invoiceId, invoice.invoice_number, pdfData);
583+
// Store PDF (or HTML fallback)
584+
const pdfKey = await pdfService.storePDF(userId, invoiceId, invoice.invoice_number, pdfData, isPdf);
581585

582586
// Update invoice
583587
const now = new Date().toISOString();
@@ -589,6 +593,7 @@ invoices.post('/:id/pdf', async (c) => {
589593
data: {
590594
message: 'PDF generated successfully',
591595
pdf_key: pdfKey,
596+
format: isPdf ? 'pdf' : 'html',
592597
},
593598
requestId,
594599
});
@@ -610,18 +615,17 @@ invoices.get('/:id/pdf', async (c) => {
610615
throw new NotFoundError('Invoice');
611616
}
612617

613-
const pdfKey = `${userId}/invoices/${invoiceId}/${invoice.invoice_number}.html`;
614-
const pdfService = new PDFService(c.env.STORAGE);
615-
const pdf = await pdfService.getPDF(pdfKey);
618+
const pdfService = new PDFService(c.env.STORAGE, c.env.CF_ACCOUNT_ID, c.env.CF_API_TOKEN);
619+
const pdf = await pdfService.getPDF(userId, invoiceId, invoice.invoice_number);
616620

617621
if (!pdf) {
618622
throw new NotFoundError('PDF');
619623
}
620624

621625
return new Response(pdf.body, {
622626
headers: {
623-
'Content-Type': 'text/html',
624-
'Content-Disposition': `inline; filename="${invoice.invoice_number}.html"`,
627+
'Content-Type': pdf.contentType,
628+
'Content-Disposition': `inline; filename="${pdf.filename}"`,
625629
},
626630
});
627631
});

apps/api/src/routes/public.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,18 +134,17 @@ publicRoutes.get('/invoice/:token/pdf', async (c) => {
134134
throw new NotFoundError('PDF');
135135
}
136136

137-
const pdfKey = `${invoice.user_id}/invoices/${tokenRecord.invoice_id}/${invoice.invoice_number}.html`;
138-
const pdfService = new PDFService(c.env.STORAGE);
139-
const pdf = await pdfService.getPDF(pdfKey);
137+
const pdfService = new PDFService(c.env.STORAGE, c.env.CF_ACCOUNT_ID, c.env.CF_API_TOKEN);
138+
const pdf = await pdfService.getPDF(invoice.user_id, tokenRecord.invoice_id, invoice.invoice_number);
140139

141140
if (!pdf) {
142141
throw new NotFoundError('PDF');
143142
}
144143

145144
return new Response(pdf.body, {
146145
headers: {
147-
'Content-Type': 'text/html',
148-
'Content-Disposition': `inline; filename="${invoice.invoice_number}.html"`,
146+
'Content-Type': pdf.contentType,
147+
'Content-Disposition': `inline; filename="${pdf.filename}"`,
149148
},
150149
});
151150
});
@@ -229,7 +228,7 @@ publicRoutes.post('/invoice/:token/pay', async (c) => {
229228
* Get demo video
230229
*/
231230
publicRoutes.get('/demo-video', async (c) => {
232-
const videoKey = 'demo/kivo-demo.mp4';
231+
const videoKey = 'demo/kivo.mp4';
233232

234233
const object = await c.env.STORAGE.get(videoKey);
235234

apps/api/src/services/email.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export class EmailService {
6767
<h2 style="color: #1a1a1a; font-size: 20px; font-weight: 600; margin: 0 0 15px 0;">Sign in to your account</h2>
6868
<p style="color: #6b7280; margin: 0 0 20px 0;">Click the button below to sign in to Kivo. This link will expire in 15 minutes.</p>
6969
70-
<a href="${magicLink}" style="display: inline-block; background: #6366F1; color: white; text-decoration: none; padding: 12px 24px; border-radius: 6px; font-weight: 500;">Sign in</a>
70+
<a href="${magicLink}" style="display: inline-block; background: #10B981; color: white; text-decoration: none; padding: 12px 24px; border-radius: 6px; font-weight: 500;">Sign in</a>
7171
</div>
7272
7373
<p style="color: #9ca3af; font-size: 14px; margin: 0;">If you did not request this email, you can safely ignore it.</p>
@@ -117,7 +117,7 @@ export class EmailService {
117117
</div>
118118
</div>
119119
120-
<a href="${publicUrl}" style="display: inline-block; background: #6366F1; color: white; text-decoration: none; padding: 12px 24px; border-radius: 6px; font-weight: 500;">View Invoice</a>
120+
<a href="${publicUrl}" style="display: inline-block; background: #10B981; color: white; text-decoration: none; padding: 12px 24px; border-radius: 6px; font-weight: 500;">View Invoice</a>
121121
</div>
122122
123123
<p style="color: #9ca3af; font-size: 14px; margin: 0;">This invoice was sent via Kivo.</p>
@@ -181,7 +181,7 @@ export class EmailService {
181181
</div>
182182
</div>
183183
184-
<a href="${publicUrl}" style="display: inline-block; background: #6366F1; color: white; text-decoration: none; padding: 12px 24px; border-radius: 6px; font-weight: 500;">View and Pay Invoice</a>
184+
<a href="${publicUrl}" style="display: inline-block; background: #10B981; color: white; text-decoration: none; padding: 12px 24px; border-radius: 6px; font-weight: 500;">View and Pay Invoice</a>
185185
</div>
186186
187187
<p style="color: #9ca3af; font-size: 14px; margin: 0;">This reminder was sent via Kivo.</p>

apps/api/src/services/pdf.ts

Lines changed: 105 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,79 @@
11
import type { InvoiceWithClient, Settings } from '@kivo/shared';
22
import { formatCurrency, formatDateForTimezone } from '@kivo/shared';
33

4-
// Simple PDF generation for Workers environment
5-
// Using a basic approach that works without native dependencies
4+
// PDF generation using Cloudflare Browser Rendering API
5+
// Falls back to HTML if the API is unavailable
6+
7+
export interface PDFResult {
8+
body: ReadableStream | ArrayBuffer;
9+
contentType: string;
10+
filename: string;
11+
}
612

713
export class PDFService {
814
private storage: R2Bucket;
15+
private accountId: string;
16+
private apiToken: string;
917

10-
constructor(storage: R2Bucket) {
18+
constructor(storage: R2Bucket, accountId: string, apiToken: string) {
1119
this.storage = storage;
20+
this.accountId = accountId;
21+
this.apiToken = apiToken;
1222
}
1323

1424
/**
15-
* Generate a PDF for an invoice
16-
* This creates a simple HTML-based PDF that can be rendered by browsers
25+
* Generate a PDF for an invoice using Cloudflare Browser Rendering API
26+
* Falls back to HTML if the API call fails
1727
*/
1828
async generateInvoicePDF(
1929
invoice: InvoiceWithClient,
2030
settings: Settings
21-
): Promise<ArrayBuffer> {
31+
): Promise<{ data: ArrayBuffer; isPdf: boolean }> {
2232
const html = this.generateInvoiceHTML(invoice, settings);
23-
24-
// Convert HTML to a simple PDF-like format
25-
// In production, you might want to use a headless browser API or a PDF service
26-
// For now, we'll store HTML that can be converted/printed to PDF
33+
34+
// Try Browser Rendering API first
35+
if (this.accountId && this.apiToken) {
36+
try {
37+
const response = await fetch(
38+
`https://api.cloudflare.com/client/v4/accounts/${this.accountId}/browser-rendering/pdf`,
39+
{
40+
method: 'POST',
41+
headers: {
42+
'Authorization': `Bearer ${this.apiToken}`,
43+
'Content-Type': 'application/json',
44+
},
45+
body: JSON.stringify({
46+
html,
47+
pdfOptions: {
48+
format: 'a4',
49+
printBackground: true,
50+
margin: {
51+
top: '20px',
52+
bottom: '20px',
53+
left: '20px',
54+
right: '20px',
55+
},
56+
},
57+
}),
58+
}
59+
);
60+
61+
if (response.ok) {
62+
const pdfBuffer = await response.arrayBuffer();
63+
return { data: pdfBuffer, isPdf: true };
64+
}
65+
66+
// Log error but continue to fallback
67+
console.error('Browser Rendering API error:', response.status, await response.text());
68+
} catch (error) {
69+
console.error('Browser Rendering API failed:', error);
70+
}
71+
}
72+
73+
// Fallback to HTML
74+
console.warn('Falling back to HTML generation');
2775
const encoder = new TextEncoder();
28-
return encoder.encode(html).buffer as ArrayBuffer;
76+
return { data: encoder.encode(html).buffer as ArrayBuffer, isPdf: false };
2977
}
3078

3179
/**
@@ -90,7 +138,7 @@ export class PDFService {
90138
.invoice-number {
91139
font-size: 28px;
92140
font-weight: 700;
93-
color: #6366F1;
141+
color: #10B981;
94142
margin-bottom: 8px;
95143
}
96144
@@ -202,9 +250,9 @@ export class PDFService {
202250
203251
.status-draft { background: #f3f4f6; color: #6b7280; }
204252
.status-sent { background: #dbeafe; color: #2563eb; }
205-
.status-viewed { background: #f3e8ff; color: #9333ea; }
253+
.status-viewed { background: #ede9fe; color: #8B5CF6; }
206254
.status-paid { background: #dcfce7; color: #16a34a; }
207-
.status-overdue { background: #fee2e2; color: #dc2626; }
255+
.status-overdue { background: #fff7ed; color: #F97316; }
208256
.status-void { background: #f1f5f9; color: #64748b; }
209257
210258
.notes {
@@ -362,14 +410,17 @@ export class PDFService {
362410
userId: string,
363411
invoiceId: string,
364412
invoiceNumber: string,
365-
pdfData: ArrayBuffer
413+
pdfData: ArrayBuffer,
414+
isPdf: boolean
366415
): Promise<string> {
367-
const key = `${userId}/invoices/${invoiceId}/${invoiceNumber}.html`;
416+
const extension = isPdf ? 'pdf' : 'html';
417+
const contentType = isPdf ? 'application/pdf' : 'text/html';
418+
const key = `${userId}/invoices/${invoiceId}/${invoiceNumber}.${extension}`;
368419

369420
await this.storage.put(key, pdfData, {
370421
httpMetadata: {
371-
contentType: 'text/html',
372-
contentDisposition: `inline; filename="${invoiceNumber}.html"`,
422+
contentType,
423+
contentDisposition: `inline; filename="${invoiceNumber}.${extension}"`,
373424
},
374425
});
375426

@@ -378,16 +429,47 @@ export class PDFService {
378429

379430
/**
380431
* Get PDF from R2
432+
* Tries PDF first, then falls back to HTML for legacy files
381433
*/
382-
async getPDF(key: string): Promise<R2ObjectBody | null> {
383-
return this.storage.get(key);
434+
async getPDF(userId: string, invoiceId: string, invoiceNumber: string): Promise<PDFResult | null> {
435+
// Try PDF first
436+
const pdfKey = `${userId}/invoices/${invoiceId}/${invoiceNumber}.pdf`;
437+
let file = await this.storage.get(pdfKey);
438+
439+
if (file) {
440+
return {
441+
body: file.body,
442+
contentType: 'application/pdf',
443+
filename: `${invoiceNumber}.pdf`,
444+
};
445+
}
446+
447+
// Fall back to HTML for legacy files
448+
const htmlKey = `${userId}/invoices/${invoiceId}/${invoiceNumber}.html`;
449+
file = await this.storage.get(htmlKey);
450+
451+
if (file) {
452+
return {
453+
body: file.body,
454+
contentType: 'text/html',
455+
filename: `${invoiceNumber}.html`,
456+
};
457+
}
458+
459+
return null;
384460
}
385461

386462
/**
387-
* Delete PDF from R2
463+
* Delete PDF from R2 (both PDF and HTML versions)
388464
*/
389-
async deletePDF(key: string): Promise<void> {
390-
await this.storage.delete(key);
465+
async deletePDF(userId: string, invoiceId: string, invoiceNumber: string): Promise<void> {
466+
const pdfKey = `${userId}/invoices/${invoiceId}/${invoiceNumber}.pdf`;
467+
const htmlKey = `${userId}/invoices/${invoiceId}/${invoiceNumber}.html`;
468+
469+
await Promise.all([
470+
this.storage.delete(pdfKey),
471+
this.storage.delete(htmlKey),
472+
]);
391473
}
392474

393475
/**

apps/api/src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export interface Env {
1818
RESEND_API_KEY: string;
1919
STRIPE_SECRET_KEY: string;
2020
STRIPE_WEBHOOK_SECRET: string;
21+
22+
// Cloudflare Browser Rendering
23+
CF_ACCOUNT_ID: string;
24+
CF_API_TOKEN: string;
2125
}
2226

2327
export interface Variables {

apps/api/wrangler.jsonc

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
// Static assets (web frontend)
88
"assets": {
99
"directory": "../web/dist",
10-
"binding": "ASSETS"
10+
"binding": "ASSETS",
11+
"not_found_handling": "single-page-application",
12+
"run_worker_first": ["/api/*", "/health"]
1113
},
1214

1315
// D1 Database
@@ -47,7 +49,8 @@
4749
"ENVIRONMENT": "production",
4850
"FRONTEND_URL": "https://kivo.lauragift.workers.dev",
4951
"API_URL": "https://kivo.lauragift.workers.dev",
50-
"FROM_EMAIL": "noreply@thegiftcode.dev"
52+
"FROM_EMAIL": "noreply@thegiftcode.dev",
53+
"CF_ACCOUNT_ID": "20d70bbc30c928fa3f30d3669ff13331"
5154
},
5255

5356
// Migrations for Durable Objects

0 commit comments

Comments
 (0)